mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Anchor transcript scrolling to entries and bound viewport rendering (#46721)
## Why Transcript-wide row offsets depend on content that can change during pagination, streaming, and resizing. Computing an exact scroll percentage also requires laying out offscreen history. ## What changed - Replace the transcript overlay's generic pager with `TranscriptView`, tracking entry identity and a display-row offset through history updates and stream consolidation. Clamp offsets within the entry on resize or replacement. - Render through bounded layout caches and show `100%` only when following the latest output with no unloaded history. - Keep the reading position while `Home` loads older pages, then jump to the beginning when loading completes. - Refresh animations whenever live output is visible, including while scrolled up. - Reveal selected prompts when backtracking and highlight their text without reversing gutters or padding. ## Testing Add regression coverage for reading anchors across pagination and live-output completion, resize and consolidation, prompt visibility and highlighting, navigation before the first draw, and avoiding offscreen formatting for the percentage indicator. GitOrigin-RevId: 105cb66f93c742ee537152419060a87f55896b44
This commit is contained in:
@@ -232,7 +232,7 @@ impl App {
|
||||
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
|
||||
let mut continue_to_start = false;
|
||||
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
|
||||
let index = overlay.prepend(cells.clone(), width);
|
||||
let index = overlay.prepend(cells.clone());
|
||||
self.transcript_cells.splice(index..index, cells);
|
||||
let previous_state = overlay.set_history_state(if self.scrollback_has_older_history {
|
||||
TranscriptHistoryState::Partial
|
||||
|
||||
@@ -2651,10 +2651,6 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
|
||||
.await;
|
||||
assert!(app.scrollback_has_older_history);
|
||||
if let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() {
|
||||
overlay.handle_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
|
||||
)?;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 16,
|
||||
);
|
||||
@@ -2670,6 +2666,9 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
// Inspect the loaded start without Home, which now requests all older pages.
|
||||
render_overlay(overlay);
|
||||
overlay.scroll(isize::MIN);
|
||||
let partial = render_overlay(overlay);
|
||||
assert!(partial.contains("Earlier messages are available — scroll up to load them"));
|
||||
assert!(!partial.contains("OpenAI Codex"));
|
||||
|
||||
@@ -328,7 +328,7 @@ impl App {
|
||||
let close_overlay = t.is_done();
|
||||
if !close_overlay
|
||||
&& active_key.is_some_and(|key| key.animation_tick.is_some())
|
||||
&& t.is_scrolled_to_bottom()
|
||||
&& t.live_tail_visible()
|
||||
{
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(std::time::Duration::from_millis(50));
|
||||
|
||||
@@ -13,7 +13,7 @@ impl App {
|
||||
event: TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if let TuiEvent::Key(key_event) = &event
|
||||
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref()
|
||||
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
|
||||
&& (overlay.should_load_older(*key_event)
|
||||
|| (self.backtrack.overlay_preview_active
|
||||
&& self.backtrack.nth_user_message == 0
|
||||
|
||||
@@ -31,8 +31,6 @@ use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::ShortcutHint;
|
||||
use crate::keymap::PagerKeymap;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::InsetRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::tui;
|
||||
@@ -50,19 +48,17 @@ 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),
|
||||
Static(StaticOverlay),
|
||||
Transcript(Box<TranscriptOverlay>),
|
||||
Static(Box<StaticOverlay>),
|
||||
Analytics(Box<crate::analytics::AnalyticsView>),
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
|
||||
Self::Transcript(TranscriptOverlay::new(cells, keymap))
|
||||
Self::Transcript(Box::new(TranscriptOverlay::new(cells, keymap)))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_lines(
|
||||
@@ -70,7 +66,7 @@ impl Overlay {
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
Self::Static(StaticOverlay::with_title(lines, title, keymap))
|
||||
Self::Static(Box::new(StaticOverlay::with_title(lines, title, keymap)))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_renderables(
|
||||
@@ -78,7 +74,11 @@ impl Overlay {
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
) -> Self {
|
||||
Self::Static(StaticOverlay::with_renderables(renderables, title, keymap))
|
||||
Self::Static(Box::new(StaticOverlay::with_renderables(
|
||||
renderables,
|
||||
title,
|
||||
keymap,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
@@ -159,11 +159,6 @@ struct PagerView {
|
||||
title: String,
|
||||
keymap: PagerKeymap,
|
||||
last_content_height: Option<usize>,
|
||||
last_rendered_height: Option<usize>,
|
||||
/// Percentages are meaningful only when the full scrollable history is known.
|
||||
scroll_percentage_visible: bool,
|
||||
/// If set, on next render ensure this chunk is visible.
|
||||
pending_scroll_chunk: Option<usize>,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
@@ -179,9 +174,6 @@ impl PagerView {
|
||||
title,
|
||||
keymap,
|
||||
last_content_height: None,
|
||||
last_rendered_height: None,
|
||||
scroll_percentage_visible: true,
|
||||
pending_scroll_chunk: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,12 +190,6 @@ impl PagerView {
|
||||
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));
|
||||
@@ -270,9 +256,6 @@ impl PagerView {
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render(sep_rect, buf);
|
||||
if !self.scroll_percentage_visible {
|
||||
return;
|
||||
}
|
||||
let percent = if total_len == 0 {
|
||||
100
|
||||
} else {
|
||||
@@ -359,53 +342,6 @@ impl PagerView {
|
||||
}
|
||||
}
|
||||
|
||||
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<dyn Renderable>,
|
||||
@@ -454,14 +390,14 @@ pub(crate) enum TranscriptHistoryState {
|
||||
}
|
||||
|
||||
impl TranscriptHistoryState {
|
||||
fn has_unloaded_history(self) -> bool {
|
||||
pub(crate) fn has_unloaded_history(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed
|
||||
)
|
||||
}
|
||||
|
||||
fn session_header_placeholder(self) -> Option<&'static str> {
|
||||
pub(crate) fn session_header_placeholder(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::LoadingOlder | Self::LoadingBeginning => Some("Loading earlier messages..."),
|
||||
Self::Partial => Some("Earlier messages are available — scroll up to load them"),
|
||||
@@ -605,26 +541,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
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 static_overlay_snapshot_basic() {
|
||||
// Prepare a static overlay with a few lines and a title
|
||||
@@ -665,84 +581,4 @@ mod tests {
|
||||
|
||||
assert_eq!(pv.content_height(/*width*/ 80), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
|
||||
let mut pv = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", /*lines*/ 1),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
paragraph_block("c", /*lines*/ 3),
|
||||
],
|
||||
"T",
|
||||
/*scroll_offset*/ 0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
|
||||
pv.scroll_offset = 0;
|
||||
let content_area = pv.content_area(area);
|
||||
pv.ensure_chunk_visible(/*idx*/ 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 = pager_view(
|
||||
vec![
|
||||
paragraph_block("a", /*lines*/ 2),
|
||||
paragraph_block("b", /*lines*/ 3),
|
||||
paragraph_block("c", /*lines*/ 3),
|
||||
],
|
||||
"T",
|
||||
/*scroll_offset*/ 0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
|
||||
pv.scroll_offset = 6;
|
||||
pv.ensure_chunk_visible(/*idx*/ 0, area);
|
||||
|
||||
assert_eq!(pv.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
||||
let mut pv = pager_view(
|
||||
vec![paragraph_block("a", /*lines*/ 10)],
|
||||
"T",
|
||||
/*scroll_offset*/ 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Reading and prompt backtracking follow current entries through live updates and completion.
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
fn user(message: &str) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
message: message.to_owned(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
spoken: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn render(overlay: &mut TranscriptOverlay, area: Rect) -> Buffer {
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
buffer
|
||||
}
|
||||
|
||||
fn content_rows(overlay: &TranscriptOverlay, buffer: &Buffer) -> Vec<String> {
|
||||
let area = overlay.content_area;
|
||||
(area.top()..area.bottom())
|
||||
.map(|y| {
|
||||
(area.left()..area.right())
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_live_output_updates_in_place_and_stays_on_the_committed_entry() {
|
||||
let mut overlay =
|
||||
TranscriptOverlay::new(vec![user("first prompt")], RuntimeKeymap::defaults().pager);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 9,
|
||||
);
|
||||
for revision in ["streaming", "updated"] {
|
||||
let lines = (0..8)
|
||||
.map(|row| HyperlinkLine::from(format!("{revision} row {row}")))
|
||||
.collect();
|
||||
overlay.sync_live_tail(area.width, /*key*/ None, |_| Some(lines));
|
||||
if revision == "streaming" {
|
||||
render(&mut overlay, area);
|
||||
overlay.scroll(/*rows*/ -2);
|
||||
}
|
||||
let buffer = render(&mut overlay, area);
|
||||
assert_eq!(
|
||||
content_rows(&overlay, &buffer),
|
||||
(2..6)
|
||||
.map(|row| format!("{revision} row {row}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(!overlay.view.is_following());
|
||||
}
|
||||
overlay.insert_cell(Arc::new(PlainHistoryCell::new(
|
||||
(0..8)
|
||||
.map(|row| Line::from(format!("completed row {row}")))
|
||||
.collect(),
|
||||
)));
|
||||
overlay.sync_live_tail(area.width, /*key*/ None, |_| None);
|
||||
overlay.insert_cell(user("queued prompt"));
|
||||
let completed = render(&mut overlay, area);
|
||||
assert_eq!(
|
||||
content_rows(&overlay, &completed),
|
||||
[
|
||||
"completed row 2",
|
||||
"completed row 3",
|
||||
"completed row 4",
|
||||
"completed row 5"
|
||||
]
|
||||
);
|
||||
overlay.set_highlight_cell(Some(2));
|
||||
let highlighted = render(&mut overlay, area);
|
||||
assert!(
|
||||
content_rows(&overlay, &highlighted)
|
||||
.iter()
|
||||
.any(|row| row == "› queued prompt")
|
||||
);
|
||||
assert!(!overlay.view.is_following());
|
||||
overlay.view.jump_to_latest();
|
||||
let latest = render(&mut overlay, area);
|
||||
let latest = content_rows(&overlay, &latest).join("\n");
|
||||
assert!(latest.contains("queued prompt"));
|
||||
assert!(!latest.contains("updated row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtracking_reveals_a_prompt_when_only_its_final_padding_is_visible() {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
vec![
|
||||
user("first prompt"),
|
||||
Arc::new(PlainHistoryCell::new(vec![
|
||||
"tail zero".into(),
|
||||
"tail one".into(),
|
||||
"tail two".into(),
|
||||
])),
|
||||
],
|
||||
RuntimeKeymap::defaults().pager,
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
let before = render(&mut overlay, area);
|
||||
assert_eq!(
|
||||
content_rows(&overlay, &before),
|
||||
["", "", "tail zero", "tail one", "tail two"]
|
||||
);
|
||||
|
||||
let first_prompt_index = 0;
|
||||
overlay.set_highlight_cell(Some(first_prompt_index));
|
||||
let highlighted = render(&mut overlay, area);
|
||||
let rows = content_rows(&overlay, &highlighted);
|
||||
let prompt_row = rows
|
||||
.iter()
|
||||
.position(|row| row == "› first prompt")
|
||||
.expect("backtracking reveals the prompt body, not just its padding");
|
||||
let y = overlay.content_area.y + prompt_row as u16;
|
||||
assert!(
|
||||
(2..2 + "first prompt".len() as u16)
|
||||
.all(|x| highlighted[(x, y)].modifier.contains(Modifier::REVERSED))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_highlight_leaves_gutters_and_padding_unreversed() {
|
||||
crate::terminal_palette::with_test_default_colors(
|
||||
crate::terminal_probe::DefaultColors {
|
||||
fg: (32, 32, 32),
|
||||
bg: (255, 255, 255),
|
||||
},
|
||||
|| {
|
||||
let mut snapshots = Vec::new();
|
||||
for (width, height) in [(40, 10), (12, 12)] {
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
vec![
|
||||
Arc::new(PlainHistoryCell::new(vec!["prior".into()])),
|
||||
user("alpha beta gamma"),
|
||||
],
|
||||
RuntimeKeymap::defaults().pager,
|
||||
);
|
||||
let first_prompt_index = 1;
|
||||
overlay.set_highlight_cell(Some(first_prompt_index));
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
let buffer = render(&mut overlay, area);
|
||||
let content = Buffer {
|
||||
area: Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
overlay.content_area.height,
|
||||
),
|
||||
content: buffer
|
||||
.content
|
||||
.chunks(usize::from(width))
|
||||
.skip(usize::from(overlay.content_area.y))
|
||||
.take(usize::from(overlay.content_area.height))
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect(),
|
||||
};
|
||||
snapshots.push(format!("{width} columns\n{content:?}"));
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n\n"));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
//! Backtrack selection preserves unrelated layout caches and the complete rendered viewport.
|
||||
|
||||
use super::*;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MeasuredCell {
|
||||
measurements: AtomicUsize,
|
||||
}
|
||||
|
||||
impl HistoryCell for MeasuredCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.measurements.fetch_add(1, Ordering::Relaxed);
|
||||
vec!["history".into()]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec!["history".into()]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_highlight_preserves_unaffected_height_caches() {
|
||||
let cells: Vec<_> = (0..32)
|
||||
.map(|_| {
|
||||
Arc::new(MeasuredCell {
|
||||
measurements: AtomicUsize::new(0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut overlay = TranscriptOverlay::new(
|
||||
cells
|
||||
.iter()
|
||||
.map(|cell| cell.clone() as Arc<dyn HistoryCell>)
|
||||
.collect(),
|
||||
RuntimeKeymap::defaults().pager,
|
||||
);
|
||||
let mut area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
|
||||
);
|
||||
overlay.render(area, &mut Buffer::empty(area));
|
||||
|
||||
for selection in [Some(30), Some(28), Some(28), None] {
|
||||
overlay.set_highlight_cell(selection);
|
||||
overlay.render(area, &mut Buffer::empty(area));
|
||||
}
|
||||
|
||||
let measurements = || {
|
||||
cells
|
||||
.iter()
|
||||
.map(|cell| cell.measurements.load(Ordering::Relaxed))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut expected = vec![1; cells.len()];
|
||||
assert_eq!(measurements(), expected);
|
||||
|
||||
// Width changes still invalidate every cached height.
|
||||
area.width = 24;
|
||||
overlay.render(area, &mut Buffer::empty(area));
|
||||
for count in &mut expected {
|
||||
*count += 1;
|
||||
}
|
||||
assert_eq!(measurements(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_highlight_matches_full_rebuild_with_live_tail() {
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = ["first prompt", "second prompt"]
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
Arc::new(UserHistoryCell {
|
||||
message: message.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
spoken: false,
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect();
|
||||
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*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(vec![HyperlinkLine::from("live tail")]),
|
||||
);
|
||||
}
|
||||
|
||||
for width in [40, 24, 40] {
|
||||
for selection in [Some(0), Some(1), Some(1), None, Some(99), Some(0)] {
|
||||
actual.set_highlight_cell(selection);
|
||||
let tail = expected.take_live_tail_renderable();
|
||||
expected.highlight_cell = selection;
|
||||
expected.rebuild_renderables(tail);
|
||||
if let Some(index) = selection {
|
||||
expected.view.scroll_chunk_into_view(index);
|
||||
}
|
||||
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 12);
|
||||
let mut actual_buffer = Buffer::empty(area);
|
||||
let mut expected_buffer = Buffer::empty(area);
|
||||
actual.render(area, &mut actual_buffer);
|
||||
expected.render(area, &mut expected_buffer);
|
||||
assert_eq!(actual_buffer, expected_buffer);
|
||||
assert_eq!(actual.view.scroll_offset, expected.view.scroll_offset);
|
||||
}
|
||||
}
|
||||
|
||||
actual.set_highlight_cell(Some(1));
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 20,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
actual.render(area, &mut buffer);
|
||||
let content = (1..area.height - 4)
|
||||
.map(|y| {
|
||||
(0..area.width)
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.map(|line| line.trim_end().to_string())
|
||||
.filter(|line| !line.is_empty() && line != "~")
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(content, @"
|
||||
› first prompt
|
||||
› second prompt
|
||||
live tail
|
||||
");
|
||||
}
|
||||
@@ -1,88 +1,8 @@
|
||||
//! Viewport-aware transcript rendering and the fallback for generic pager content.
|
||||
//! Viewport fallback for generic static pager content.
|
||||
|
||||
use crate::transcript_view::LayoutCache;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
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::HyperlinkParagraph;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
/// 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,
|
||||
pub(super) cache: Rc<RefCell<LayoutCache>>,
|
||||
}
|
||||
|
||||
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 layout = self
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.get(&self.cell, area.width, /*separated*/ false);
|
||||
let style = if self.cell.as_any().is::<UserHistoryCell>() {
|
||||
if self.highlighted {
|
||||
user_message_style().reversed()
|
||||
} else {
|
||||
user_message_style()
|
||||
}
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
buf.set_style(area, style);
|
||||
layout.render(area, buf, usize::from(scroll_offset));
|
||||
true
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.cache
|
||||
.borrow_mut()
|
||||
.get(&self.cell, width, /*separated*/ false)
|
||||
.row_count()
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
HyperlinkParagraph::new(&self.lines, Style::default())
|
||||
.scroll(scroll_offset)
|
||||
.render(area, buf);
|
||||
true
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
HyperlinkParagraph::new(&self.lines, Style::default())
|
||||
.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(
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
//! Static pager fallback preserves styled buffer slices at arbitrary scroll offsets.
|
||||
|
||||
use super::super::CachedRenderable;
|
||||
use super::CellRenderable;
|
||||
use super::HyperlinkLinesRenderable;
|
||||
use super::render_offset_content;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
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;
|
||||
@@ -17,180 +13,54 @@ 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 {
|
||||
spoken: false,
|
||||
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 {
|
||||
cache: Default::default(),
|
||||
cell: cell.clone(),
|
||||
highlighted: false,
|
||||
}),
|
||||
),
|
||||
(
|
||||
"highlighted cached user history cell",
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cache: Default::default(),
|
||||
cell: user,
|
||||
highlighted: true,
|
||||
})),
|
||||
),
|
||||
(
|
||||
"inset cached history cell",
|
||||
#[test]
|
||||
fn scrolled_static_renderables_match_clipped_buffer_slices() {
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
"prefix ".green(),
|
||||
"漢字 ガ ".bold(),
|
||||
"styled words that wrap across rows".blue(),
|
||||
]),
|
||||
Line::default(),
|
||||
Line::from("last 漢字 ガ row".red()),
|
||||
];
|
||||
for width in [5, 7, 13, 28] {
|
||||
let paragraph = || Paragraph::new(Text::from(lines.clone())).wrap(Wrap { trim: false });
|
||||
let renderables: Vec<Box<dyn Renderable>> = vec![
|
||||
Box::new(paragraph()),
|
||||
Box::new(CachedRenderable::new(paragraph())),
|
||||
Box::new(InsetRenderable::new(
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cache: Default::default(),
|
||||
cell,
|
||||
highlighted: false,
|
||||
})) as Box<dyn Renderable>,
|
||||
Box::new(CachedRenderable::new(paragraph())) 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,
|
||||
};
|
||||
];
|
||||
for renderable in renderables {
|
||||
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 offset in [0, 1, 2, 3, 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 canvas = Rect::new(/*x*/ 0, /*y*/ 0, area.right(), area.bottom());
|
||||
let mut expected = Buffer::empty(canvas);
|
||||
let mut actual = Buffer::empty(canvas);
|
||||
let expected_height = visible_height.min(height.saturating_sub(offset));
|
||||
let full_area =
|
||||
Rect::new(/*x*/ 0, /*y*/ 0, width, offset + expected_height);
|
||||
let mut full = Buffer::empty(full_area);
|
||||
renderable.render(full_area, &mut full);
|
||||
for row in 0..expected_height {
|
||||
for column in 0..width {
|
||||
expected[(x + column, y + row)] = full[(column, offset + row)].clone();
|
||||
}
|
||||
}
|
||||
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:?}",
|
||||
"width={width}, offset={offset}, area={area:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
---
|
||||
source: tui/src/pager_overlay/anchor_tests.rs
|
||||
assertion_line: 187
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
40 columns
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 40, height: 5 },
|
||||
content: [
|
||||
"prior ",
|
||||
" ",
|
||||
" ",
|
||||
"› alpha beta gamma ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM,
|
||||
x: 2, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
|
||||
x: 18, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
12 columns
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 12, height: 7 },
|
||||
content: [
|
||||
"prior ",
|
||||
" ",
|
||||
" ",
|
||||
"› alpha ",
|
||||
" beta ",
|
||||
" gamma ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
|
||||
x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM,
|
||||
x: 2, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
|
||||
x: 7, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
|
||||
x: 2, y: 4, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
|
||||
x: 6, y: 4, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
|
||||
x: 2, y: 5, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
|
||||
x: 7, y: 5, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -10,6 +10,6 @@ expression: snapshot
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
─────────────────────────────────────────────────────────────────────────── 0% ─
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
↑/↓ to scroll pgup/pgdn to page home/end to jump
|
||||
q close esc to edit prev
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
---
|
||||
source: tui/src/pager_overlay/transcript_tests.rs
|
||||
expression: "buffer_to_text(&actual, area)"
|
||||
---
|
||||
/ T R A N S C R I P T / / / / / / / / /
|
||||
first
|
||||
last
|
||||
~
|
||||
~
|
||||
~
|
||||
───────────────────────────────── 100% ─
|
||||
↑/↓ to scroll pgup/pgdn to page hom
|
||||
q close esc to edit prev
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/pager_overlay/transcript_tests.rs
|
||||
expression: visible_before.trim()
|
||||
---
|
||||
line2
|
||||
|
||||
line3
|
||||
@@ -1,432 +1,302 @@
|
||||
//! Detailed transcript overlay over committed history and the current live tail.
|
||||
//! Detailed transcript overlay with entry-based scrolling and the existing pager chrome.
|
||||
//!
|
||||
//! The overlay owns committed cells and input bindings. Its viewport displays current content
|
||||
//! and keeps readers on the same entry through pagination and stream consolidation.
|
||||
|
||||
use super::*;
|
||||
use crate::transcript_view::LayoutCache;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use crate::transcript_view::TranscriptView;
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
/// Pager UI state and the renderables currently displayed.
|
||||
///
|
||||
/// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing
|
||||
/// live-tail renderable appended after the committed cells.
|
||||
view: PagerView,
|
||||
cache: Rc<RefCell<LayoutCache>>,
|
||||
/// Committed transcript cells (does not include the live tail).
|
||||
view: TranscriptView,
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
keymap: PagerKeymap,
|
||||
highlight_cell: Option<usize>,
|
||||
/// Cache key for the render-only live tail appended after committed cells.
|
||||
live_tail_key: Option<LiveTailKey>,
|
||||
history_state: TranscriptHistoryState,
|
||||
reveal_highlight: bool,
|
||||
content_area: Rect,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
/// Cache key for the active-cell "live tail" appended to the transcript overlay.
|
||||
///
|
||||
/// Changing any field implies a different rendered tail.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct LiveTailKey {
|
||||
/// Current terminal width, which affects wrapping.
|
||||
width: u16,
|
||||
/// Revision that changes on in-place active cell transcript updates.
|
||||
revision: u64,
|
||||
/// Whether the tail should be treated as a continuation for spacing.
|
||||
is_stream_continuation: bool,
|
||||
/// Optional animation tick to refresh spinners/progress indicators.
|
||||
animation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
impl TranscriptOverlay {
|
||||
/// Creates a transcript overlay for a fixed set of committed cells.
|
||||
///
|
||||
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
|
||||
/// `sync_live_tail` during draws to reflect in-flight activity.
|
||||
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
|
||||
let cache = Rc::new(RefCell::new(LayoutCache::default()));
|
||||
pub(crate) fn new(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
Self::render_cells(
|
||||
&cache,
|
||||
&transcript_cells,
|
||||
/*highlight_cell*/ None,
|
||||
TranscriptHistoryState::Idle,
|
||||
),
|
||||
"T R A N S C R I P T".to_string(),
|
||||
usize::MAX,
|
||||
keymap,
|
||||
),
|
||||
cache,
|
||||
cells: transcript_cells,
|
||||
view: TranscriptView::default(),
|
||||
cells,
|
||||
keymap,
|
||||
highlight_cell: None,
|
||||
live_tail_key: None,
|
||||
history_state: TranscriptHistoryState::Idle,
|
||||
reveal_highlight: false,
|
||||
content_area: Rect::default(),
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let top_height = area.height.saturating_sub(/*rhs*/ 3);
|
||||
let top = Rect::new(area.x, area.y, area.width, top_height);
|
||||
self.content_area = Rect::new(
|
||||
area.x,
|
||||
area.y.saturating_add(/*rhs*/ 1),
|
||||
area.width,
|
||||
top_height.saturating_sub(/*rhs*/ 2),
|
||||
)
|
||||
.intersection(area);
|
||||
let mut rendered =
|
||||
self.view
|
||||
.render(self.content_area, buf, &self.cells, self.highlight_cell);
|
||||
if std::mem::take(&mut self.reveal_highlight)
|
||||
&& let Some(index) = self.highlight_cell
|
||||
{
|
||||
self.view.ensure_entry_visible(&self.cells, index);
|
||||
rendered = self
|
||||
.view
|
||||
.render(self.content_area, buf, &self.cells, self.highlight_cell);
|
||||
}
|
||||
if self.view.history == TranscriptHistoryState::LoadingBeginning
|
||||
&& self.view.is_following()
|
||||
&& !self.content_area.is_empty()
|
||||
{
|
||||
// Home may arrive before the first draw; retain the first real viewport.
|
||||
self.view.scroll(&self.cells, /*rows*/ 0);
|
||||
}
|
||||
if area.width > 0 {
|
||||
for y in self.content_area.y + rendered..self.content_area.bottom() {
|
||||
"~".render(Rect::new(area.x, y, /*width*/ 1, /*height*/ 1), buf);
|
||||
}
|
||||
}
|
||||
let header = Rect::new(area.x, area.y, area.width, area.height.min(/*other*/ 1));
|
||||
Span::from("/ ".repeat(usize::from(area.width) / 2))
|
||||
.dim()
|
||||
.render(header, buf);
|
||||
"/ T R A N S C R I P T".dim().render(header, buf);
|
||||
self.render_history_state(top, buf);
|
||||
let separator = Rect::new(
|
||||
area.x,
|
||||
self.content_area.bottom(),
|
||||
area.width,
|
||||
/*height*/ 1,
|
||||
)
|
||||
.intersection(area);
|
||||
"─"
|
||||
.repeat(usize::from(separator.width))
|
||||
.dim()
|
||||
.render(separator, buf);
|
||||
// An exact intermediate percentage would require laying out every offscreen cell.
|
||||
// Only the loaded tail has a known percentage without defeating bounded rendering.
|
||||
if self.view.is_following()
|
||||
&& !self.view.history.has_unloaded_history()
|
||||
&& separator.width >= 7
|
||||
{
|
||||
" 100% ".dim().render(
|
||||
Rect::new(
|
||||
separator.right() - 7,
|
||||
separator.y,
|
||||
/*width*/ 6,
|
||||
separator.height,
|
||||
),
|
||||
buf,
|
||||
);
|
||||
}
|
||||
let footer =
|
||||
Rect::new(area.x, area.y + top_height, area.width, /*height*/ 3).intersection(area);
|
||||
self.render_hints(footer, buf);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match event {
|
||||
TuiEvent::Key(key) => {
|
||||
if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key)
|
||||
{
|
||||
self.is_done = true;
|
||||
} else if self.navigate(key) {
|
||||
tui.frame_requester()
|
||||
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
|
||||
}
|
||||
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 set_history_state(
|
||||
&mut self,
|
||||
state: TranscriptHistoryState,
|
||||
) -> TranscriptHistoryState {
|
||||
let previous = self.history_state;
|
||||
if previous == state {
|
||||
return previous;
|
||||
let previous = std::mem::replace(&mut self.view.history, state);
|
||||
if state == TranscriptHistoryState::LoadingBeginning
|
||||
&& previous != TranscriptHistoryState::LoadingBeginning
|
||||
&& !self.content_area.is_empty()
|
||||
{
|
||||
// App marks loading before forwarding Home, so record the current position here.
|
||||
self.view.scroll(&self.cells, /*rows*/ 0);
|
||||
}
|
||||
if previous == TranscriptHistoryState::LoadingBeginning
|
||||
&& state == TranscriptHistoryState::Complete
|
||||
{
|
||||
self.view.scroll_offset = 0;
|
||||
}
|
||||
self.history_state = state;
|
||||
self.view.scroll_percentage_visible = !state.has_unloaded_history();
|
||||
if self
|
||||
.cells
|
||||
.iter()
|
||||
.any(|cell| cell.as_any().is::<SessionInfoCell>())
|
||||
{
|
||||
let live_tail = self.take_live_tail_renderable();
|
||||
self.rebuild_renderables(live_tail);
|
||||
self.view.jump_to_entry(&self.cells, /*index*/ 0);
|
||||
}
|
||||
previous
|
||||
}
|
||||
|
||||
fn render_cells(
|
||||
cache: &Rc<RefCell<LayoutCache>>,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
highlight_cell: Option<usize>,
|
||||
history_state: TranscriptHistoryState,
|
||||
) -> Vec<Box<dyn Renderable>> {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cell)| Self::render_cell(cache, cell, i, highlight_cell, history_state))
|
||||
.collect()
|
||||
pub(crate) fn should_load_older(&mut self, key: KeyEvent) -> bool {
|
||||
self.should_load_from_start(key)
|
||||
|| (self.view.near_start(&self.cells)
|
||||
&& [
|
||||
&self.keymap.scroll_up,
|
||||
&self.keymap.page_up,
|
||||
&self.keymap.half_page_up,
|
||||
]
|
||||
.iter()
|
||||
.any(|bindings| bindings.is_pressed(key)))
|
||||
}
|
||||
|
||||
/// Build the renderable for a committed cell, caching its height when the cell is stable.
|
||||
fn render_cell(
|
||||
cache: &Rc<RefCell<LayoutCache>>,
|
||||
cell: &Arc<dyn HistoryCell>,
|
||||
index: usize,
|
||||
highlight_cell: Option<usize>,
|
||||
history_state: TranscriptHistoryState,
|
||||
) -> Box<dyn Renderable> {
|
||||
if cell.as_any().is::<SessionInfoCell>()
|
||||
&& let Some(placeholder) = history_state.session_header_placeholder()
|
||||
{
|
||||
return Box::new(Line::from(placeholder).dim());
|
||||
}
|
||||
let cell_renderable = CellRenderable {
|
||||
cache: Rc::clone(cache),
|
||||
cell: cell.clone(),
|
||||
highlighted: highlight_cell == Some(index),
|
||||
};
|
||||
let mut cell_renderable: Box<dyn Renderable> = if cell.has_stable_transcript_height() {
|
||||
Box::new(CachedRenderable::new(cell_renderable))
|
||||
} else {
|
||||
Box::new(cell_renderable)
|
||||
};
|
||||
if !cell.is_stream_continuation() && index > 0 {
|
||||
cell_renderable = Box::new(InsetRenderable::new(
|
||||
cell_renderable,
|
||||
Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
),
|
||||
));
|
||||
}
|
||||
cell_renderable
|
||||
pub(crate) fn should_load_from_start(&self, key: KeyEvent) -> bool {
|
||||
self.keymap.jump_top.is_pressed(key)
|
||||
}
|
||||
|
||||
/// Insert a committed history cell while keeping any cached live tail.
|
||||
///
|
||||
/// The live tail is temporarily removed, the new committed cell is appended,
|
||||
/// then the tail is reattached. If the tail previously had no leading
|
||||
/// spacing because it was the only renderable, we add the missing inset
|
||||
/// when the first committed cell arrives.
|
||||
///
|
||||
/// This expects `cell` to be a committed transcript cell (not the in-flight active cell). If
|
||||
/// the overlay was scrolled to bottom before insertion, it remains pinned to bottom after the
|
||||
/// insertion to preserve the "follow along" behavior.
|
||||
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
let had_prior_cells = !self.cells.is_empty();
|
||||
let tail_renderable = self.take_live_tail_renderable();
|
||||
let cell_renderable = Self::render_cell(
|
||||
&self.cache,
|
||||
&cell,
|
||||
self.cells.len(),
|
||||
self.highlight_cell,
|
||||
self.history_state,
|
||||
);
|
||||
let index = self.cells.len();
|
||||
self.view
|
||||
.replace_range(&self.cells, index..index + 1, &cell);
|
||||
self.cells.push(cell);
|
||||
self.view.renderables.push(cell_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)
|
||||
{
|
||||
// The tail was rendered as the only entry, so it lacks a top
|
||||
// inset; add one now that it follows a committed cell.
|
||||
Box::new(InsetRenderable::new(
|
||||
tail,
|
||||
Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
),
|
||||
)) as Box<dyn Renderable>
|
||||
} else {
|
||||
tail
|
||||
};
|
||||
self.view.renderables.push(tail);
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether an upward navigation is close enough to request older history.
|
||||
pub(crate) fn should_load_older(&self, key_event: KeyEvent) -> bool {
|
||||
self.should_load_from_start(key_event)
|
||||
|| (self.view.scroll_offset
|
||||
<= self.view.last_content_height.unwrap_or(/*default*/ 0)
|
||||
&& (self.view.keymap.scroll_up.is_pressed(key_event)
|
||||
|| self.view.keymap.page_up.is_pressed(key_event)
|
||||
|| self.view.keymap.half_page_up.is_pressed(key_event)))
|
||||
}
|
||||
|
||||
pub(crate) fn should_load_from_start(&self, key_event: KeyEvent) -> bool {
|
||||
self.view.keymap.jump_top.is_pressed(key_event)
|
||||
}
|
||||
|
||||
/// Prepends history without moving visible content and returns its insertion index.
|
||||
pub(crate) fn prepend(&mut self, cells: Vec<Arc<dyn HistoryCell>>, width: u16) -> usize {
|
||||
/// Prepends history and returns the insertion index for the canonical transcript.
|
||||
pub(crate) fn prepend(&mut self, cells: Vec<Arc<dyn HistoryCell>>) -> usize {
|
||||
if cells.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
let previous_height = self.view.content_height(width);
|
||||
let live_tail = self.take_live_tail_renderable();
|
||||
let added_cells = cells.len();
|
||||
let insert_at = self
|
||||
let index = self
|
||||
.cells
|
||||
.iter()
|
||||
.rposition(|cell| cell.as_any().is::<SessionInfoCell>())
|
||||
.map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1));
|
||||
self.cells.splice(insert_at..insert_at, cells);
|
||||
for index in [
|
||||
&mut self.highlight_cell,
|
||||
&mut self.view.pending_scroll_chunk,
|
||||
] {
|
||||
if let Some(index) = index.as_mut()
|
||||
&& *index >= insert_at
|
||||
{
|
||||
*index = index.saturating_add(added_cells);
|
||||
}
|
||||
}
|
||||
self.rebuild_renderables(live_tail);
|
||||
let content_height = self.view.content_height(width);
|
||||
self.view.scroll_offset = if follow_bottom {
|
||||
usize::MAX
|
||||
} else {
|
||||
self.view
|
||||
.scroll_offset
|
||||
.saturating_add(content_height.saturating_sub(previous_height))
|
||||
};
|
||||
self.view.last_rendered_height = Some(content_height);
|
||||
insert_at
|
||||
}
|
||||
|
||||
/// Replace committed transcript cells while keeping any cached in-progress output that is
|
||||
/// currently shown at the end of the overlay.
|
||||
///
|
||||
/// This is used when existing history is replaced or trimmed so the
|
||||
/// transcript overlay immediately reflects the same committed cells as the main transcript.
|
||||
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
let live_tail = self.take_live_tail_renderable();
|
||||
self.cells = cells;
|
||||
if self
|
||||
.map_or(/*default*/ 0, |index| index + 1);
|
||||
let added = cells.len();
|
||||
self.cells.splice(index..index, cells);
|
||||
if let Some(highlight) = self
|
||||
.highlight_cell
|
||||
.is_some_and(|idx| idx >= self.cells.len())
|
||||
.as_mut()
|
||||
.filter(|highlight| **highlight >= index)
|
||||
{
|
||||
self.highlight_cell = None;
|
||||
}
|
||||
self.rebuild_renderables(live_tail);
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
*highlight += added;
|
||||
}
|
||||
self.view.history_changed(&self.cells);
|
||||
index
|
||||
}
|
||||
|
||||
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
|
||||
self.cells = cells;
|
||||
self.view.history_changed(&self.cells);
|
||||
self.highlight_cell = self
|
||||
.highlight_cell
|
||||
.filter(|index| *index < self.cells.len());
|
||||
}
|
||||
|
||||
/// Replace a range of committed cells with a single consolidated cell.
|
||||
///
|
||||
/// Mirrors the splice performed on `App::transcript_cells` during
|
||||
/// `ConsolidateAgentMessage` so the Ctrl+T overlay stays in sync with the
|
||||
/// main transcript. The range is clamped defensively: cells may have been
|
||||
/// inserted after the overlay opened, leaving it with fewer entries than
|
||||
/// the main transcript.
|
||||
pub(crate) fn consolidate_cells(
|
||||
&mut self,
|
||||
range: std::ops::Range<usize>,
|
||||
consolidated: Arc<dyn HistoryCell>,
|
||||
) {
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
// Clamp the range to the overlay's cell count to avoid panic if the overlay has fewer
|
||||
// cells than the main transcript (e.g. cells were inserted after the overlay has opened).
|
||||
let clamped_end = range.end.min(self.cells.len());
|
||||
let clamped_start = range.start.min(clamped_end);
|
||||
if clamped_start < clamped_end {
|
||||
let live_tail = self.take_live_tail_renderable();
|
||||
let removed = clamped_end - clamped_start;
|
||||
if let Some(highlight_cell) = self.highlight_cell.as_mut()
|
||||
&& *highlight_cell >= clamped_start
|
||||
{
|
||||
if *highlight_cell < clamped_end {
|
||||
*highlight_cell = clamped_start;
|
||||
} else {
|
||||
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
self.cells
|
||||
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
|
||||
if self
|
||||
.highlight_cell
|
||||
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
|
||||
{
|
||||
self.highlight_cell = None;
|
||||
}
|
||||
self.rebuild_renderables(live_tail);
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
let end = range.end.min(self.cells.len());
|
||||
let start = range.start.min(end);
|
||||
if start == end {
|
||||
return;
|
||||
}
|
||||
self.view
|
||||
.replace_range(&self.cells, start..end, &consolidated);
|
||||
self.highlight_cell = self.highlight_cell.map(|index| {
|
||||
if index < start {
|
||||
index
|
||||
} else if index < end {
|
||||
start
|
||||
} else {
|
||||
index - (end - start - 1)
|
||||
}
|
||||
});
|
||||
self.cells.splice(start..end, [consolidated]);
|
||||
}
|
||||
|
||||
/// Sync the active-cell live tail with the current width and cell state.
|
||||
///
|
||||
/// Recomputes the tail only when the cache key changes, preserving scroll
|
||||
/// position and dropping the tail if there is nothing to render.
|
||||
///
|
||||
/// The overlay owns committed transcript cells while the live tail is derived from the current
|
||||
/// active cell, which can mutate in place while streaming. `App` calls this during
|
||||
/// `TuiEvent::Draw` for `Overlay::Transcript`, passing a key that changes when the active cell
|
||||
/// mutates or animates so the cached tail stays fresh.
|
||||
///
|
||||
/// Passing a key that does not change on in-place active-cell mutations will freeze the tail in
|
||||
/// `Ctrl+T` while the main viewport continues to update.
|
||||
pub(crate) fn sync_live_tail(
|
||||
&mut self,
|
||||
width: u16,
|
||||
active_key: Option<ActiveCellTranscriptKey>,
|
||||
key: Option<ActiveCellTranscriptKey>,
|
||||
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 active_key.is_some_and(|key| key.cacheable) && self.live_tail_key == next_key {
|
||||
return;
|
||||
}
|
||||
let follow_bottom = self.view.is_scrolled_to_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.view.renderables.push(Self::live_tail_renderable(
|
||||
lines,
|
||||
!self.cells.is_empty(),
|
||||
key.is_stream_continuation,
|
||||
));
|
||||
}
|
||||
}
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
self.view.sync_live_tail(width, key, compute_lines);
|
||||
}
|
||||
|
||||
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
||||
let previous = self.highlight_cell;
|
||||
self.highlight_cell = cell;
|
||||
// Highlighting changes only these cells' styling. Keep the other renderables and their
|
||||
// cached heights so moving between prompts does not lay out the entire transcript again.
|
||||
if previous != cell {
|
||||
for index in [previous, cell].into_iter().flatten() {
|
||||
if let Some(history_cell) = self.cells.get(index) {
|
||||
self.view.renderables[index] = Self::render_cell(
|
||||
&self.cache,
|
||||
history_cell,
|
||||
index,
|
||||
self.highlight_cell,
|
||||
self.history_state,
|
||||
);
|
||||
}
|
||||
self.highlight_cell = cell.filter(|index| *index < self.cells.len());
|
||||
self.reveal_highlight = self.highlight_cell.is_some();
|
||||
}
|
||||
|
||||
pub(crate) fn scroll(&mut self, rows: isize) {
|
||||
if std::mem::take(&mut self.reveal_highlight)
|
||||
&& let Some(index) = self.highlight_cell
|
||||
{
|
||||
self.view.ensure_entry_visible(&self.cells, index);
|
||||
}
|
||||
self.view.scroll(&self.cells, rows);
|
||||
}
|
||||
|
||||
fn navigate(&mut self, key: KeyEvent) -> bool {
|
||||
if self.keymap.jump_top.is_pressed(key) {
|
||||
if self.content_area.is_empty() && self.view.history.has_unloaded_history() {
|
||||
self.set_history_state(TranscriptHistoryState::LoadingBeginning);
|
||||
} else {
|
||||
self.view.jump_to_beginning(&self.cells);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if let Some(idx) = self.highlight_cell {
|
||||
self.view.scroll_chunk_into_view(idx);
|
||||
if self.keymap.jump_bottom.is_pressed(key) {
|
||||
self.view.jump_to_latest();
|
||||
return true;
|
||||
}
|
||||
let page = self.content_area.height.max(/*other*/ 1) as isize;
|
||||
let half = (page + 1) / 2;
|
||||
let delta = [
|
||||
(&self.keymap.scroll_up, -1),
|
||||
(&self.keymap.scroll_down, 1),
|
||||
(&self.keymap.page_up, -page),
|
||||
(&self.keymap.page_down, page),
|
||||
(&self.keymap.half_page_up, -half),
|
||||
(&self.keymap.half_page_down, half),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(bindings, delta)| bindings.is_pressed(key).then_some(delta));
|
||||
let Some(delta) = delta else {
|
||||
return false;
|
||||
};
|
||||
if !self.content_area.is_empty() {
|
||||
self.scroll(delta);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns whether the underlying pager view is currently pinned to the bottom.
|
||||
///
|
||||
/// The `App` draw loop uses this to decide whether to schedule animation frames for the live
|
||||
/// tail; if the user has scrolled up, we avoid driving animation work that they cannot see.
|
||||
pub(crate) fn is_scrolled_to_bottom(&self) -> bool {
|
||||
self.view.is_scrolled_to_bottom()
|
||||
pub(crate) fn live_tail_visible(&self) -> bool {
|
||||
self.view.live_tail_visible()
|
||||
}
|
||||
|
||||
// Detach the live tail before changing cells: their old count identifies the tail renderable.
|
||||
fn rebuild_renderables(&mut self, tail_renderable: Option<Box<dyn Renderable>>) {
|
||||
self.view.renderables = Self::render_cells(
|
||||
&self.cache,
|
||||
&self.cells,
|
||||
self.highlight_cell,
|
||||
self.history_state,
|
||||
);
|
||||
if let Some(tail) = tail_renderable {
|
||||
self.view.renderables.push(tail);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes and returns the cached live-tail renderable, if present.
|
||||
///
|
||||
/// The live tail is represented as a single optional renderable appended after the committed
|
||||
/// cell renderables, so this relies on the live tail always being the final entry in
|
||||
/// `view.renderables` when present.
|
||||
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
|
||||
(self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())?
|
||||
}
|
||||
|
||||
fn live_tail_renderable(
|
||||
lines: Vec<HyperlinkLine>,
|
||||
has_prior_cells: bool,
|
||||
is_stream_continuation: bool,
|
||||
) -> Box<dyn Renderable> {
|
||||
let mut renderable: Box<dyn Renderable> =
|
||||
Box::new(CachedRenderable::new(HyperlinkLinesRenderable { lines }));
|
||||
if has_prior_cells && !is_stream_continuation {
|
||||
renderable = Box::new(InsetRenderable::new(
|
||||
renderable,
|
||||
Insets::tlbr(
|
||||
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
|
||||
),
|
||||
));
|
||||
}
|
||||
renderable
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
}
|
||||
|
||||
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 line1 = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area);
|
||||
let line2 = Rect::new(
|
||||
area.x,
|
||||
area.y.saturating_add(/*rhs*/ 1),
|
||||
area.width,
|
||||
/*height*/ 1,
|
||||
)
|
||||
.intersection(area);
|
||||
render_navigation_hints(line1, buf, &self.keymap);
|
||||
|
||||
let mut pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
|
||||
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
|
||||
first_or_empty(&self.keymap, "close", &self.keymap.close),
|
||||
"close",
|
||||
)];
|
||||
if self.highlight_cell.is_some() {
|
||||
@@ -448,25 +318,11 @@ impl TranscriptOverlay {
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
self.cache.borrow_mut().begin_frame();
|
||||
// Preserve following the tail before the composer changes the available height.
|
||||
if self.view.is_scrolled_to_bottom() {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
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_history_state(top, buf);
|
||||
self.render_hints(bottom, buf);
|
||||
}
|
||||
|
||||
fn render_history_state(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let label = match self.history_state {
|
||||
let label = match self.view.history {
|
||||
TranscriptHistoryState::Idle => return,
|
||||
TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => {
|
||||
" loading older history... "
|
||||
@@ -486,36 +342,10 @@ impl TranscriptOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
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 self.view.keymap.close.is_pressed(e)
|
||||
|| self.view.keymap.close_transcript.is_pressed(e) =>
|
||||
{
|
||||
self.is_done = true;
|
||||
Ok(())
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
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)]
|
||||
#[path = "transcript_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "highlight_tests.rs"]
|
||||
mod highlight_tests;
|
||||
#[path = "anchor_tests.rs"]
|
||||
mod anchor_tests;
|
||||
|
||||
@@ -41,13 +41,13 @@ impl crate::history_cell::HistoryCell for TestCell {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HeightCountingCell {
|
||||
height_calls: Arc<AtomicUsize>,
|
||||
struct LayoutCountingCell {
|
||||
layout_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl crate::history_cell::HistoryCell for HeightCountingCell {
|
||||
impl crate::history_cell::HistoryCell for LayoutCountingCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.height_calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.layout_calls.fetch_add(/*val*/ 1, Ordering::Relaxed);
|
||||
vec![Line::from("counted")]
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
|
||||
|
||||
#[test]
|
||||
fn jump_top_requests_older_history_from_the_bottom() {
|
||||
let overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("recent")],
|
||||
})]);
|
||||
|
||||
@@ -81,22 +81,149 @@ fn jump_top_requests_older_history_from_the_bottom() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_next_hint_is_visible_when_highlighted() {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("hello")],
|
||||
})]);
|
||||
overlay.set_highlight_cell(Some(0));
|
||||
|
||||
// Render into a wide buffer so the footer hints aren't truncated.
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
let s = buffer_to_text(&buf, area);
|
||||
assert!(
|
||||
s.contains("edit next"),
|
||||
"expected 'edit next' hint in overlay footer, got: {s:?}"
|
||||
fn first_upward_key_requests_history_when_the_loaded_start_is_visible() {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
for committed in [false, true] {
|
||||
for (line_count, near_start) in [(0, true), (3, true), (7, true), (30, false)] {
|
||||
let lines = (0..line_count)
|
||||
.map(|index| Line::from(format!("line {index}")))
|
||||
.collect::<Vec<_>>();
|
||||
let cells = if committed && !lines.is_empty() {
|
||||
vec![Arc::new(TestCell {
|
||||
lines: lines.clone(),
|
||||
}) as Arc<dyn HistoryCell>]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut overlay = transcript_overlay(cells);
|
||||
if !committed {
|
||||
overlay.sync_live_tail(area.width, /*key*/ None, |_| {
|
||||
Some(lines.into_iter().map(HyperlinkLine::from).collect())
|
||||
});
|
||||
}
|
||||
overlay.set_history_state(TranscriptHistoryState::Partial);
|
||||
overlay.render(area, &mut Buffer::empty(area));
|
||||
assert!(overlay.view.is_following());
|
||||
|
||||
// App checks pagination before forwarding the first navigation key.
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||||
] {
|
||||
assert_eq!(overlay.should_load_older(key), near_start);
|
||||
}
|
||||
assert!(!overlay.should_load_older(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE,)));
|
||||
if line_count == 30 {
|
||||
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
|
||||
overlay.scroll(/*rows*/ -1);
|
||||
assert!(
|
||||
!overlay.should_load_older(up),
|
||||
"one row must not prefetch a distant page"
|
||||
);
|
||||
overlay.scroll(/*rows*/ -18);
|
||||
assert!(
|
||||
!overlay.should_load_older(up),
|
||||
"row six is outside the five-row prefetch threshold"
|
||||
);
|
||||
overlay.scroll(/*rows*/ -1);
|
||||
assert!(
|
||||
overlay.should_load_older(up),
|
||||
"prefetch at row five even before a draw"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loading_beginning_keeps_the_entry_until_history_is_complete() -> Result<()> {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
for already_rendered in [false, true] {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec!["committed".into()],
|
||||
})]);
|
||||
let live = (0..20)
|
||||
.map(|index| Line::from(format!("live {index}")))
|
||||
.collect::<Vec<_>>();
|
||||
overlay.sync_live_tail(area.width, /*key*/ None, |_| {
|
||||
Some(live.iter().cloned().map(HyperlinkLine::from).collect())
|
||||
});
|
||||
overlay.set_history_state(TranscriptHistoryState::Partial);
|
||||
if already_rendered {
|
||||
overlay.render(area, &mut Buffer::empty(area));
|
||||
}
|
||||
|
||||
// App marks loading before forwarding Home to the overlay.
|
||||
overlay.set_history_state(TranscriptHistoryState::LoadingBeginning);
|
||||
overlay.handle_event(
|
||||
&mut tui,
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
|
||||
)?;
|
||||
let mut before = Buffer::empty(area);
|
||||
overlay.render(area, &mut before);
|
||||
assert!(!overlay.view.is_following());
|
||||
assert_eq!(
|
||||
overlay.view.history,
|
||||
TranscriptHistoryState::LoadingBeginning
|
||||
);
|
||||
|
||||
overlay.insert_cell(Arc::new(TestCell { lines: live }));
|
||||
overlay.sync_live_tail(area.width, /*key*/ None, |_| {
|
||||
Some(
|
||||
(20..40)
|
||||
.map(|index| HyperlinkLine::from(format!("live {index}")))
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
overlay.prepend(vec![Arc::new(TestCell {
|
||||
lines: vec!["oldest".into()],
|
||||
})]);
|
||||
let mut actual = Buffer::empty(area);
|
||||
overlay.render(area, &mut actual);
|
||||
assert_eq!(actual, before);
|
||||
|
||||
overlay.set_history_state(TranscriptHistoryState::Complete);
|
||||
overlay.render(area, &mut actual);
|
||||
assert!(buffer_to_text(&actual, overlay.content_area).starts_with("oldest\n"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn navigation_before_the_first_draw_preserves_the_full_latest_viewport() -> Result<()> {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
|
||||
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||
] {
|
||||
let cells = vec![Arc::new(TestCell {
|
||||
lines: (0..30)
|
||||
.map(|index| Line::from(format!("line {index}")))
|
||||
.collect(),
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
let mut expected_overlay = transcript_overlay(cells.clone());
|
||||
let mut expected = Buffer::empty(area);
|
||||
expected_overlay.render(area, &mut expected);
|
||||
let mut overlay = transcript_overlay(cells);
|
||||
overlay.handle_event(&mut tui, TuiEvent::Key(key))?;
|
||||
let mut actual = Buffer::empty(area);
|
||||
overlay.render(area, &mut actual);
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -146,24 +273,35 @@ fn transcript_overlay_snapshot_basic() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_semantic_web_links() {
|
||||
fn transcript_overlay_preserves_complete_wrapped_links_in_history_and_live_output() {
|
||||
let destination = "https://example.com/a/very/long/path";
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new(
|
||||
let cell: Arc<dyn HistoryCell> = Arc::new(history_cell::AgentMarkdownCell::new(
|
||||
destination.to_string(),
|
||||
std::path::Path::new("/tmp"),
|
||||
))]);
|
||||
));
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
assert!(area.positions().any(|position| {
|
||||
buf[position]
|
||||
.symbol()
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
}));
|
||||
for live in [false, true] {
|
||||
let mut overlay = transcript_overlay(if live { Vec::new() } else { vec![cell.clone()] });
|
||||
if live {
|
||||
overlay.sync_live_tail(area.width, /*key*/ None, |width| {
|
||||
Some(cell.transcript_hyperlink_lines(width))
|
||||
});
|
||||
}
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
let linked_text = area
|
||||
.positions()
|
||||
.filter_map(|position| {
|
||||
let symbol = buf[position].symbol();
|
||||
symbol
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
.then(|| crate::terminal_hyperlinks::strip_osc8(symbol))
|
||||
})
|
||||
.collect::<String>();
|
||||
assert_eq!(linked_text, destination);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -186,69 +324,67 @@ fn transcript_overlay_renders_live_tail() {
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(term.backend());
|
||||
overlay.navigate(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert!(overlay.live_tail_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_live_tail_when_prepending_history() {
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("recent")],
|
||||
})]);
|
||||
overlay.sync_live_tail(
|
||||
/*width*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(vec![HyperlinkLine::from("live tail")]),
|
||||
);
|
||||
overlay.prepend(
|
||||
vec![Arc::new(TestCell {
|
||||
lines: vec![Line::from("older")],
|
||||
})],
|
||||
/*width*/ 40,
|
||||
);
|
||||
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
let rendered = buffer_to_text(&buffer, area);
|
||||
assert!(rendered.contains("older"));
|
||||
assert!(rendered.contains("recent"));
|
||||
assert!(rendered.contains("live tail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_live_tail_preserves_semantic_web_links() {
|
||||
let destination = "https://example.com/a/streamed/path";
|
||||
let cell =
|
||||
history_cell::AgentMarkdownCell::new(destination.to_string(), std::path::Path::new("/tmp"));
|
||||
let mut overlay = transcript_overlay(Vec::new());
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.sync_live_tail(
|
||||
area.width,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|width| Some(cell.transcript_hyperlink_lines(width)),
|
||||
);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
assert!(area.positions().any(|position| {
|
||||
buf[position]
|
||||
.symbol()
|
||||
.contains(&format!("\x1b]8;;{destination}\x07"))
|
||||
}));
|
||||
fn transcript_overlay_prepend_preserves_reading_highlight_and_live_tail() {
|
||||
for follow in [false, true] {
|
||||
let mut overlay = transcript_overlay(vec![
|
||||
Arc::new(TestCell {
|
||||
lines: vec!["newer one".into(), "newer two".into()],
|
||||
}),
|
||||
Arc::new(TestCell {
|
||||
lines: (0..20)
|
||||
.map(|i| Line::from(format!("reading {i}")))
|
||||
.collect(),
|
||||
}),
|
||||
]);
|
||||
overlay.highlight_cell = Some(1);
|
||||
overlay.sync_live_tail(
|
||||
/*width*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
}),
|
||||
|_| Some(vec![HyperlinkLine::from("live tail")]),
|
||||
);
|
||||
let mut term = Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 8)).expect("term");
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw");
|
||||
if !follow {
|
||||
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 1);
|
||||
}
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw");
|
||||
let content_area = overlay.content_area;
|
||||
let before = buffer_to_text(term.backend().buffer(), content_area);
|
||||
overlay.reveal_highlight = true;
|
||||
overlay.prepend(vec![Arc::new(TestCell {
|
||||
lines: vec!["older one".into(), "older two".into(), "older three".into()],
|
||||
})]);
|
||||
assert_eq!(
|
||||
(overlay.highlight_cell, overlay.reveal_highlight),
|
||||
(Some(2), true)
|
||||
);
|
||||
overlay.reveal_highlight = false;
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw prepended");
|
||||
assert_eq!(
|
||||
buffer_to_text(term.backend().buffer(), content_area),
|
||||
before
|
||||
);
|
||||
assert_eq!(overlay.view.is_following(), follow);
|
||||
overlay.view.jump_to_latest();
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw tail");
|
||||
assert!(buffer_to_text(term.backend().buffer(), content_area).contains("live tail"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -351,7 +487,7 @@ fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() {
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.render(area, &mut buf);
|
||||
overlay.view.scroll_offset = 0;
|
||||
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
let snapshot = buffer_to_text(&buf, area);
|
||||
@@ -374,7 +510,7 @@ fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
|
||||
.expect("draw");
|
||||
|
||||
assert!(
|
||||
overlay.view.is_scrolled_to_bottom(),
|
||||
overlay.view.is_following(),
|
||||
"expected initial render to leave view at bottom"
|
||||
);
|
||||
|
||||
@@ -382,318 +518,227 @@ fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
|
||||
term.backend_mut().resize(/*width*/ 40, height);
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw after composer height change");
|
||||
assert!(overlay.is_scrolled_to_bottom());
|
||||
assert!(overlay.view.is_following());
|
||||
}
|
||||
|
||||
overlay.insert_cell(Arc::new(TestCell {
|
||||
lines: vec!["tail".into()],
|
||||
}));
|
||||
|
||||
assert_eq!(overlay.view.scroll_offset, usize::MAX);
|
||||
assert!(overlay.view.is_following());
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw committed tail");
|
||||
assert_snapshot!("transcript_overlay_follows_resized_tail", term.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_preserves_manual_scroll_position() {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..20)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.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);
|
||||
overlay.view.scroll_offset = 3;
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw");
|
||||
let content_area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 1, /*width*/ 40, /*height*/ 4,
|
||||
);
|
||||
let visible_before = buffer_to_text(term.backend().buffer(), content_area);
|
||||
overlay.prepend(
|
||||
vec![Arc::new(TestCell {
|
||||
lines: (0..40).map(|i| Line::from(format!("older {i}"))).collect(),
|
||||
})],
|
||||
/*width*/ 40,
|
||||
);
|
||||
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_eq!(
|
||||
buffer_to_text(term.backend().buffer(), content_area),
|
||||
visible_before
|
||||
);
|
||||
assert_snapshot!(
|
||||
"transcript_overlay_prepended_history",
|
||||
visible_before.trim()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_insert_preserves_cached_cell_heights() {
|
||||
let height_calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(HeightCountingCell {
|
||||
height_calls: height_calls.clone(),
|
||||
})]);
|
||||
let area = Rect::new(0, 0, 40, 12);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
overlay.render(area, &mut buf);
|
||||
assert_eq!(height_calls.load(Ordering::Relaxed), 1);
|
||||
|
||||
overlay.insert_cell(Arc::new(TestCell {
|
||||
lines: vec![Line::from("inserted")],
|
||||
}));
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
assert_eq!(height_calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_history_rebuild_preserves_only_the_live_tail() {
|
||||
for replace in [false, true] {
|
||||
for tail in [
|
||||
None,
|
||||
Some(Vec::new()),
|
||||
Some(vec![HyperlinkLine::from("live")]),
|
||||
] {
|
||||
let mut overlay = transcript_overlay(
|
||||
["first", "last"]
|
||||
.map(|line| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![line.into()],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.to_vec(),
|
||||
);
|
||||
let key = tail.as_ref().map(|_| ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
});
|
||||
overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone());
|
||||
let consolidated = Arc::new(TestCell {
|
||||
lines: vec!["first".into(), "last".into()],
|
||||
});
|
||||
if replace {
|
||||
overlay.replace_cells(vec![consolidated]);
|
||||
} else {
|
||||
overlay.consolidate_cells(0..2, consolidated);
|
||||
}
|
||||
// A draw may arrive after the active tail has already been cleared.
|
||||
overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone());
|
||||
let mut reopened = transcript_overlay(overlay.cells.clone());
|
||||
reopened.sync_live_tail(/*width*/ 40, key, |_| tail.clone());
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
let mut actual = Buffer::empty(area);
|
||||
let mut expected = Buffer::empty(area);
|
||||
overlay.render(area, &mut actual);
|
||||
reopened.render(area, &mut expected);
|
||||
assert_eq!(actual, expected);
|
||||
if tail.is_none() {
|
||||
assert_snapshot!(
|
||||
"transcript_overlay_completed_stream",
|
||||
buffer_to_text(&actual, area)
|
||||
);
|
||||
}
|
||||
}
|
||||
fn transcript_overlay_consolidation_remaps_highlight() {
|
||||
for (selected, expected) in [(3, 2), (6, 4)] {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..7)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(selected));
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
assert_eq!(overlay.highlight_cell, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..6)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(3));
|
||||
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.highlight_cell,
|
||||
Some(2),
|
||||
"highlight inside consolidated range should point to replacement cell",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..7)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line{i}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
overlay.set_highlight_cell(Some(6));
|
||||
|
||||
overlay.consolidate_cells(
|
||||
2..5,
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("consolidated")],
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
overlay.highlight_cell,
|
||||
Some(4),
|
||||
"highlight after consolidated range should shift left by removed cells",
|
||||
);
|
||||
}
|
||||
|
||||
fn transcript_line_numbers(overlay: &mut TranscriptOverlay, area: Rect) -> Vec<usize> {
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
let top_h = area.height.saturating_sub(3);
|
||||
let top = Rect::new(area.x, area.y, area.width, top_h);
|
||||
let content_area = overlay.view.content_area(top);
|
||||
|
||||
let mut nums = Vec::new();
|
||||
for y in content_area.y..content_area.bottom() {
|
||||
let mut line = String::new();
|
||||
for x in content_area.x..content_area.right() {
|
||||
line.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
if let Some(n) = line
|
||||
.split_whitespace()
|
||||
.find_map(|w| w.strip_prefix("line-"))
|
||||
.and_then(|s| s.parse().ok())
|
||||
{
|
||||
nums.push(n);
|
||||
}
|
||||
}
|
||||
nums
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_paging_is_continuous_and_round_trips() {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..50)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line-{i:02}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: (0..50)
|
||||
.map(|i| Line::from(format!("line-{i:02}")))
|
||||
.collect(),
|
||||
})]);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 15,
|
||||
);
|
||||
let area = Rect::new(0, 0, 40, 15);
|
||||
|
||||
// Prime layout so last_content_height is populated and paging uses the real content height.
|
||||
let mut buf = Buffer::empty(area);
|
||||
overlay.view.scroll_offset = 0;
|
||||
overlay.render(area, &mut buf);
|
||||
let page_height = overlay.view.page_height(area);
|
||||
|
||||
// Scenario 1: starting from the top, PageDown should show the next page of content.
|
||||
overlay.view.scroll_offset = 0;
|
||||
let page1 = transcript_line_numbers(&mut overlay, area);
|
||||
let page1_len = page1.len();
|
||||
let expected_page1: Vec<usize> = (0..page1_len).collect();
|
||||
assert_eq!(
|
||||
page1, expected_page1,
|
||||
"first page should start at line-00 and show a full page of content"
|
||||
);
|
||||
|
||||
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height);
|
||||
let page2 = transcript_line_numbers(&mut overlay, area);
|
||||
assert_eq!(
|
||||
page2.len(),
|
||||
page1_len,
|
||||
"second page should have the same number of visible lines as the first page"
|
||||
);
|
||||
let expected_page2_first = *page1.last().unwrap() + 1;
|
||||
assert_eq!(
|
||||
page2[0], expected_page2_first,
|
||||
"second page after PageDown should immediately follow the first page"
|
||||
);
|
||||
|
||||
// Scenario 2: from an interior offset (start=3), PageDown then PageUp should round-trip.
|
||||
let interior_offset = 3usize;
|
||||
overlay.view.scroll_offset = interior_offset;
|
||||
let before = transcript_line_numbers(&mut overlay, area);
|
||||
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height);
|
||||
let _ = transcript_line_numbers(&mut overlay, area);
|
||||
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height);
|
||||
let after = transcript_line_numbers(&mut overlay, area);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"PageDown+PageUp from interior offset ({interior_offset}) should round-trip"
|
||||
);
|
||||
|
||||
// Scenario 3: from the top of the second page, PageUp then PageDown should round-trip.
|
||||
overlay.view.scroll_offset = page_height;
|
||||
let before2 = transcript_line_numbers(&mut overlay, area);
|
||||
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height);
|
||||
let _ = transcript_line_numbers(&mut overlay, area);
|
||||
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height);
|
||||
let after2 = transcript_line_numbers(&mut overlay, area);
|
||||
assert_eq!(
|
||||
before2, after2,
|
||||
"PageUp+PageDown from the top of the second page should round-trip"
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0);
|
||||
for start in [0, 10] {
|
||||
overlay.render(area, &mut buffer);
|
||||
assert_eq!(
|
||||
buffer_to_text(&buffer, overlay.content_area),
|
||||
(start..start + 10)
|
||||
.map(|i| format!("line-{i:02}\n"))
|
||||
.collect::<String>()
|
||||
);
|
||||
overlay.navigate(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
|
||||
}
|
||||
overlay.scroll(/*rows*/ -17);
|
||||
overlay.render(area, &mut buffer);
|
||||
let before = buffer.clone();
|
||||
for key in [KeyCode::PageDown, KeyCode::PageUp] {
|
||||
overlay.navigate(KeyEvent::new(key, KeyModifiers::NONE));
|
||||
overlay.render(area, &mut buffer);
|
||||
}
|
||||
assert_eq!(buffer, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn half_page_uses_the_last_rendered_content_height() -> Result<()> {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..50)
|
||||
.map(|i| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("line-{i:02}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: (0..50)
|
||||
.map(|i| Line::from(format!("line-{i:02}")))
|
||||
.collect(),
|
||||
);
|
||||
})]);
|
||||
let transcript_area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
|
||||
);
|
||||
let mut buf = Buffer::empty(transcript_area);
|
||||
overlay.render(transcript_area, &mut buf);
|
||||
let page_height = overlay.view.page_height(transcript_area);
|
||||
let page_height = usize::from(overlay.content_area.height);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 24,
|
||||
));
|
||||
overlay.view.scroll_offset = 10;
|
||||
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0);
|
||||
overlay.scroll(/*rows*/ 10);
|
||||
|
||||
overlay.view.handle_key_event(
|
||||
overlay.handle_event(
|
||||
&mut tui,
|
||||
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||
TuiEvent::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
overlay.view.scroll_offset,
|
||||
10 + page_height.saturating_add(1) / 2
|
||||
);
|
||||
overlay.render(transcript_area, &mut buf);
|
||||
let first = 10 + page_height.div_ceil(/*rhs*/ 2);
|
||||
assert!(buffer_to_text(&buf, overlay.content_area).starts_with(&format!("line-{first:02}\n")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_highlight_scrolls_offscreen_entries_in_both_directions() {
|
||||
let mut overlay = transcript_overlay(
|
||||
(0..20)
|
||||
.map(|index| {
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from(format!("entry {index}"))],
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
for index in [0, 19, 0] {
|
||||
overlay.set_highlight_cell(Some(index));
|
||||
overlay.render(area, &mut buffer);
|
||||
assert!(buffer_to_text(&buffer, overlay.content_area).contains(&format!("entry {index}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_footer_does_not_format_offscreen_history_for_a_percentage() {
|
||||
let counts = (0..200)
|
||||
.map(|_| Arc::new(AtomicUsize::new(/*v*/ 0)))
|
||||
.collect::<Vec<_>>();
|
||||
let mut overlay = transcript_overlay(
|
||||
counts
|
||||
.iter()
|
||||
.map(|count| {
|
||||
Arc::new(LayoutCountingCell {
|
||||
layout_calls: Arc::clone(count),
|
||||
}) as Arc<dyn HistoryCell>
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 2, /*y*/ 1, /*width*/ 72, /*height*/ 12,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.set_history_state(TranscriptHistoryState::Partial);
|
||||
overlay.render(area, &mut buffer);
|
||||
assert_eq!(
|
||||
overlay.content_area,
|
||||
Rect::new(
|
||||
/*x*/ 2, /*y*/ 2, /*width*/ 72, /*height*/ 7
|
||||
)
|
||||
);
|
||||
assert!(!buffer_to_text(&buffer, area).contains('%'));
|
||||
let before = counts
|
||||
.iter()
|
||||
.map(|count| count.load(Ordering::Relaxed))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(before[..190].iter().all(|count| *count == 0));
|
||||
assert!((1..10).contains(&before.iter().sum::<usize>()));
|
||||
overlay.set_history_state(TranscriptHistoryState::Complete);
|
||||
overlay.render(area, &mut buffer);
|
||||
assert!(buffer_to_text(&buffer, area).contains("100%"));
|
||||
assert_eq!(
|
||||
counts
|
||||
.iter()
|
||||
.map(|count| count.load(Ordering::Relaxed))
|
||||
.collect::<Vec<_>>(),
|
||||
before
|
||||
);
|
||||
for selection in [Some(199), Some(198), None] {
|
||||
overlay.set_highlight_cell(selection);
|
||||
overlay.render(area, &mut buffer);
|
||||
assert_eq!(
|
||||
counts
|
||||
.iter()
|
||||
.map(|count| count.load(Ordering::Relaxed))
|
||||
.collect::<Vec<_>>(),
|
||||
before
|
||||
);
|
||||
if selection.is_some() {
|
||||
assert!(buffer_to_text(&buffer, area).contains("edit next"));
|
||||
}
|
||||
}
|
||||
overlay.view.jump_to_latest();
|
||||
overlay.insert_cell(Arc::new(TestCell {
|
||||
lines: vec!["new output".into()],
|
||||
}));
|
||||
overlay.render(area, &mut buffer);
|
||||
assert_eq!(
|
||||
counts
|
||||
.iter()
|
||||
.map(|count| count.load(Ordering::Relaxed))
|
||||
.collect::<Vec<_>>(),
|
||||
before
|
||||
);
|
||||
overlay.scroll(/*rows*/ -1);
|
||||
overlay.render(area, &mut buffer);
|
||||
assert!(!buffer_to_text(&buffer, area).contains('%'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_clips_zero_and_short_viewports_to_their_area() {
|
||||
let canvas = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16,
|
||||
);
|
||||
for width in [0, 1, 40] {
|
||||
for height in 0..=6 {
|
||||
let area = Rect::new(/*x*/ 2, /*y*/ 3, width, height);
|
||||
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
|
||||
lines: vec!["content".into()],
|
||||
})]);
|
||||
let mut buffer = Buffer::filled(canvas, Cell::from('.'));
|
||||
overlay.render(area, &mut buffer);
|
||||
for position in canvas
|
||||
.positions()
|
||||
.filter(|position| !area.contains(*position))
|
||||
{
|
||||
assert_eq!(
|
||||
buffer[position],
|
||||
Cell::from('.'),
|
||||
"area={area:?}, position={position:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,411 @@
|
||||
//! Retained display rows and bounded caches for transcript rendering.
|
||||
//! A conversation viewport over the app's retained cells and its current live tail.
|
||||
//!
|
||||
//! The app owns history and pagination. This view owns reading state and bounded
|
||||
//! layout caches. Reading positions track entry identity and a display-row offset.
|
||||
//! Resize and replacement clamp that offset; live content always uses its current revision.
|
||||
|
||||
mod layout;
|
||||
mod text;
|
||||
|
||||
pub(crate) use layout::LayoutCache;
|
||||
pub(crate) use text::TextLayout;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::TranscriptHistoryState;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
use layout::LayoutCache;
|
||||
use text::TextLayout;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
enum EntryKey {
|
||||
Cell(usize),
|
||||
Live,
|
||||
}
|
||||
|
||||
impl EntryKey {
|
||||
fn cell(cell: &Arc<dyn HistoryCell>) -> Self {
|
||||
Self::Cell(Arc::as_ptr(cell).cast::<()>() as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct Anchor {
|
||||
key: EntryKey,
|
||||
index: usize,
|
||||
row: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
enum Position {
|
||||
#[default]
|
||||
Latest,
|
||||
Reading(Anchor),
|
||||
}
|
||||
|
||||
struct VisibleRow {
|
||||
index: usize,
|
||||
row: usize,
|
||||
layout: Arc<TextLayout>,
|
||||
key: EntryKey,
|
||||
}
|
||||
|
||||
/// Entry-based scrolling for the detailed transcript overlay.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TranscriptView {
|
||||
position: Position,
|
||||
cache: LayoutCache,
|
||||
live: Option<Arc<TextLayout>>,
|
||||
live_separated: Option<Arc<TextLayout>>,
|
||||
live_key: Option<(u16, ActiveCellTranscriptKey)>,
|
||||
live_continuation: bool,
|
||||
area: Rect,
|
||||
visible: Vec<VisibleRow>,
|
||||
pub(crate) history: TranscriptHistoryState,
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
pub(crate) fn render(
|
||||
&mut self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
highlight: Option<usize>,
|
||||
) -> u16 {
|
||||
self.cache.begin_frame();
|
||||
Clear.render(area, buf);
|
||||
self.area = area;
|
||||
self.visible.clear();
|
||||
if area.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut rendered = 0;
|
||||
let (mut index, mut row) = self.start(cells);
|
||||
for y in area.top()..area.bottom() {
|
||||
let Some(layout) = self.layout(cells, index) else {
|
||||
break;
|
||||
};
|
||||
if row >= layout.row_count() {
|
||||
index += 1;
|
||||
row = 0;
|
||||
let Some(next) = self.next_nonempty(cells, index) else {
|
||||
break;
|
||||
};
|
||||
index = next;
|
||||
}
|
||||
let Some(layout) = self.layout(cells, index) else {
|
||||
break;
|
||||
};
|
||||
let key = self.entry_key(cells, index);
|
||||
let row_area = Rect::new(area.x, y, area.width, /*height*/ 1);
|
||||
if (index == 0 || row > 0)
|
||||
&& cells
|
||||
.get(index)
|
||||
.is_some_and(|cell| cell.as_any().is::<UserHistoryCell>())
|
||||
{
|
||||
buf.set_style(row_area, crate::style::history_prompt_style());
|
||||
}
|
||||
self.visible.push(VisibleRow {
|
||||
index,
|
||||
row,
|
||||
layout,
|
||||
key,
|
||||
});
|
||||
row += 1;
|
||||
rendered += 1;
|
||||
}
|
||||
let mut y = area.y;
|
||||
for rows in self
|
||||
.visible
|
||||
.chunk_by(|a, b| Arc::ptr_eq(&a.layout, &b.layout))
|
||||
{
|
||||
let first = &rows[0];
|
||||
let height = rows.len() as u16;
|
||||
first
|
||||
.layout
|
||||
.render(Rect::new(area.x, y, area.width, height), buf, first.row);
|
||||
if highlight == Some(first.index) {
|
||||
for (offset, row) in rows.iter().enumerate() {
|
||||
row.layout.highlight(
|
||||
Rect::new(area.x, y + offset as u16, area.width, /*height*/ 1),
|
||||
buf,
|
||||
row.row,
|
||||
);
|
||||
}
|
||||
}
|
||||
y += height;
|
||||
}
|
||||
rendered
|
||||
}
|
||||
|
||||
pub(crate) fn sync_live_tail(
|
||||
&mut self,
|
||||
width: u16,
|
||||
key: Option<ActiveCellTranscriptKey>,
|
||||
lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
|
||||
) {
|
||||
let next = key.map(|key| (width, key));
|
||||
if key.is_some_and(|key| key.cacheable) && self.live_key == next {
|
||||
return;
|
||||
}
|
||||
self.live_key = next;
|
||||
self.live_separated = None;
|
||||
self.live_continuation = key.is_some_and(|key| key.is_stream_continuation);
|
||||
self.live = lines(width).map(|lines| Arc::new(TextLayout::new(lines, width)));
|
||||
}
|
||||
|
||||
pub(crate) fn is_following(&self) -> bool {
|
||||
self.position == Position::Latest
|
||||
}
|
||||
|
||||
pub(crate) fn live_tail_visible(&self) -> bool {
|
||||
self.visible.iter().any(|row| row.key == EntryKey::Live)
|
||||
}
|
||||
|
||||
/// Hold the current reading position until every older page has arrived.
|
||||
pub(crate) fn jump_to_beginning(&mut self, cells: &[Arc<dyn HistoryCell>]) {
|
||||
if self.history == TranscriptHistoryState::LoadingBeginning {
|
||||
return;
|
||||
}
|
||||
if self.history.has_unloaded_history() {
|
||||
self.scroll(cells, /*rows*/ 0);
|
||||
self.history = TranscriptHistoryState::LoadingBeginning;
|
||||
} else {
|
||||
self.jump_to_entry(cells, /*index*/ 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn jump_to_latest(&mut self) {
|
||||
self.position = Position::Latest;
|
||||
}
|
||||
|
||||
pub(crate) fn scroll(&mut self, cells: &[Arc<dyn HistoryCell>], rows: isize) {
|
||||
let start = self.start(cells);
|
||||
let (index, row) = self.move_rows(cells, start.0, start.1, rows);
|
||||
if rows < 0
|
||||
&& (index, row) == start
|
||||
&& self.is_following()
|
||||
&& !self.history.has_unloaded_history()
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Anchor visible content, not a hidden header that stays ahead of every older page.
|
||||
let index = self.next_nonempty(cells, index).unwrap_or(index);
|
||||
let bottom = self.bottom_start(cells);
|
||||
if rows > 0 && (index, row) >= bottom {
|
||||
self.jump_to_latest();
|
||||
return;
|
||||
}
|
||||
if self.layout(cells, index).is_some() {
|
||||
self.position = Position::Reading(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn jump_to_entry(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) {
|
||||
self.position = Position::Reading(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
row: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal an entire entry when prompt backtracking changes the highlight.
|
||||
pub(crate) fn ensure_entry_visible(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) {
|
||||
let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell);
|
||||
let mut visible = self
|
||||
.visible
|
||||
.iter()
|
||||
.filter(|row| row.index == index && row.key == key);
|
||||
let fully_visible = visible
|
||||
.next()
|
||||
.is_some_and(|first| first.row == 0 && visible.count() + 1 == first.layout.row_count());
|
||||
if !fully_visible {
|
||||
self.jump_to_entry(cells, index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep readers on the replacement entry without translating source offsets.
|
||||
pub(crate) fn replace_range(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
range: std::ops::Range<usize>,
|
||||
replacement: &Arc<dyn HistoryCell>,
|
||||
) {
|
||||
if let Position::Reading(anchor) = self.position
|
||||
&& range.contains(&self.resolve(cells, anchor))
|
||||
{
|
||||
self.position = Position::Reading(Anchor {
|
||||
key: EntryKey::cell(replacement),
|
||||
index: range.start,
|
||||
..anchor
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve display rows when retained entries gain or lose a leading separator.
|
||||
pub(crate) fn history_changed(&mut self, cells: &[Arc<dyn HistoryCell>]) {
|
||||
if let Position::Reading(anchor) = self.position {
|
||||
let index = self.resolve(cells, anchor);
|
||||
let separated = cells.get(index).is_some_and(|cell| {
|
||||
EntryKey::cell(cell) == anchor.key && !cell.is_stream_continuation()
|
||||
});
|
||||
let row = if separated && anchor.index == 0 && index > 0 {
|
||||
anchor.row.saturating_add(/*rhs*/ 1)
|
||||
} else if separated && anchor.index > 0 && index == 0 {
|
||||
anchor.row.saturating_sub(/*rhs*/ 1)
|
||||
} else {
|
||||
anchor.row
|
||||
};
|
||||
self.position = Position::Reading(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
row,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn near_start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
|
||||
if self.is_following() && self.area.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let (index, row) = self.start(cells);
|
||||
self.move_rows(cells, index, row, -(self.area.height as isize)) == (0, 0)
|
||||
}
|
||||
|
||||
fn start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> (usize, usize) {
|
||||
match self.position {
|
||||
Position::Latest => self.bottom_start(cells),
|
||||
Position::Reading(anchor) => {
|
||||
let index = self.resolve(cells, anchor);
|
||||
let row = self.layout(cells, index).map_or(/*default*/ 0, |layout| {
|
||||
anchor.row.min(layout.row_count().saturating_sub(/*rhs*/ 1))
|
||||
});
|
||||
self.position = Position::Reading(Anchor {
|
||||
key: self.entry_key(cells, index),
|
||||
index,
|
||||
row,
|
||||
});
|
||||
(index, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bottom_start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> (usize, usize) {
|
||||
let count = cells.len() + usize::from(self.live.is_some());
|
||||
let Some(last) = count.checked_sub(/*rhs*/ 1) else {
|
||||
return (0, 0);
|
||||
};
|
||||
let height = self
|
||||
.layout(cells, last)
|
||||
.map_or(/*default*/ 0, |l| l.row_count());
|
||||
self.move_rows(cells, last, height, -(self.area.height as isize))
|
||||
}
|
||||
|
||||
fn move_rows(
|
||||
&mut self,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
mut index: usize,
|
||||
mut row: usize,
|
||||
rows: isize,
|
||||
) -> (usize, usize) {
|
||||
if rows < 0 {
|
||||
let mut remaining = rows.unsigned_abs();
|
||||
while remaining > row && index > 0 {
|
||||
remaining -= row;
|
||||
index -= 1;
|
||||
row = self
|
||||
.layout(cells, index)
|
||||
.map_or(/*default*/ 0, |l| l.row_count());
|
||||
}
|
||||
return (index, row.saturating_sub(remaining));
|
||||
}
|
||||
let mut remaining = row.saturating_add(rows as usize);
|
||||
while let Some(layout) = self.layout(cells, index) {
|
||||
if remaining < layout.row_count() {
|
||||
break;
|
||||
}
|
||||
remaining -= layout.row_count();
|
||||
index += 1;
|
||||
}
|
||||
(index, remaining)
|
||||
}
|
||||
|
||||
fn next_nonempty(&mut self, cells: &[Arc<dyn HistoryCell>], mut index: usize) -> Option<usize> {
|
||||
while let Some(layout) = self.layout(cells, index) {
|
||||
if layout.row_count() > 0 {
|
||||
return Some(index);
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve(&self, cells: &[Arc<dyn HistoryCell>], anchor: Anchor) -> usize {
|
||||
if anchor.key == EntryKey::Live {
|
||||
return if self.live.is_some() {
|
||||
cells.len()
|
||||
} else {
|
||||
cells.len().saturating_sub(/*rhs*/ 1)
|
||||
};
|
||||
}
|
||||
if cells
|
||||
.get(anchor.index)
|
||||
.is_some_and(|cell| EntryKey::cell(cell) == anchor.key)
|
||||
{
|
||||
return anchor.index;
|
||||
}
|
||||
cells
|
||||
.iter()
|
||||
.position(|cell| EntryKey::cell(cell) == anchor.key)
|
||||
.unwrap_or_else(|| anchor.index.min(cells.len().saturating_sub(/*rhs*/ 1)))
|
||||
}
|
||||
|
||||
fn entry_key(&self, cells: &[Arc<dyn HistoryCell>], index: usize) -> EntryKey {
|
||||
cells.get(index).map_or(EntryKey::Live, EntryKey::cell)
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptView {
|
||||
fn layout(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) -> Option<Arc<TextLayout>> {
|
||||
let Some(cell) = cells.get(index) else {
|
||||
if index != cells.len() {
|
||||
return None;
|
||||
}
|
||||
let live = self.live.as_ref()?;
|
||||
if cells.is_empty() || self.live_continuation {
|
||||
return Some(Arc::clone(live));
|
||||
}
|
||||
return Some(Arc::clone(self.live_separated.get_or_insert_with(|| {
|
||||
Arc::new(live.as_ref().clone().with_leading_separator())
|
||||
})));
|
||||
};
|
||||
let width = self.area.width.max(/*other*/ 1);
|
||||
if cell.as_any().is::<crate::history_cell::SessionInfoCell>()
|
||||
&& let Some(placeholder) = self.history.session_header_placeholder()
|
||||
{
|
||||
return Some(Arc::new(TextLayout::new(
|
||||
vec![HyperlinkLine::from(Line::from(placeholder).dim())],
|
||||
width,
|
||||
)));
|
||||
}
|
||||
Some(
|
||||
self.cache
|
||||
.get(cell, width, index > 0 && !cell.is_stream_continuation()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transcript_view_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -13,7 +13,7 @@ const MAX_CACHED_ENTRIES: usize = 64;
|
||||
const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LayoutCache {
|
||||
pub(super) struct LayoutCache {
|
||||
entries: Vec<CachedLayout>,
|
||||
frame: u64,
|
||||
render_state: Option<RenderState>,
|
||||
@@ -37,7 +37,7 @@ struct CachedLayout {
|
||||
}
|
||||
|
||||
impl LayoutCache {
|
||||
pub(crate) fn begin_frame(&mut self) {
|
||||
pub(super) fn begin_frame(&mut self) {
|
||||
self.frame = self.frame.wrapping_add(/*rhs*/ 1);
|
||||
let state = RenderState {
|
||||
theme: crate::render::highlight::syntax_theme_revision(),
|
||||
@@ -51,7 +51,7 @@ impl LayoutCache {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(
|
||||
pub(super) fn get(
|
||||
&mut self,
|
||||
cell: &Arc<dyn HistoryCell>,
|
||||
width: u16,
|
||||
|
||||
@@ -2,26 +2,30 @@
|
||||
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use crate::width::display_width;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::CellWidth;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TextLayout {
|
||||
pub(crate) rows: Vec<TextRow>,
|
||||
pub(crate) byte_len: usize,
|
||||
pub(super) struct TextLayout {
|
||||
pub(super) rows: Vec<TextRow>,
|
||||
pub(super) byte_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TextRow {
|
||||
pub(crate) line: Arc<HyperlinkLine>,
|
||||
pub(crate) offset: u16,
|
||||
pub(super) struct TextRow {
|
||||
pub(super) line: Arc<HyperlinkLine>,
|
||||
pub(super) offset: u16,
|
||||
}
|
||||
|
||||
impl TextLayout {
|
||||
pub(crate) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
|
||||
pub(super) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
|
||||
let byte_len = lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.line.spans)
|
||||
@@ -51,7 +55,7 @@ impl TextLayout {
|
||||
Self { rows, byte_len }
|
||||
}
|
||||
|
||||
pub(crate) fn with_leading_separator(mut self) -> Self {
|
||||
pub(super) fn with_leading_separator(mut self) -> Self {
|
||||
if !self.rows.is_empty() {
|
||||
self.rows.insert(
|
||||
/*index*/ 0,
|
||||
@@ -64,11 +68,11 @@ impl TextLayout {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn row_count(&self) -> usize {
|
||||
pub(super) fn row_count(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer, row: usize) {
|
||||
pub(super) fn render(&self, area: Rect, buf: &mut Buffer, row: usize) {
|
||||
let start = row.min(self.rows.len());
|
||||
let end = row
|
||||
.saturating_add(usize::from(area.height))
|
||||
@@ -83,4 +87,47 @@ impl TextLayout {
|
||||
y += height;
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlight the entry's text while leaving synthetic gutters and padding untouched.
|
||||
pub(super) fn highlight(&self, area: Rect, buf: &mut Buffer, row: usize) {
|
||||
let row = &self.rows[row];
|
||||
let line = &row.line;
|
||||
let text = line.line.to_string();
|
||||
let prefix = if row.offset == 0 {
|
||||
line.source.as_ref().map_or(
|
||||
/*default*/ 0,
|
||||
|source| display_width(&text[..source.prefix_bytes]),
|
||||
)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let width = display_width(&text);
|
||||
let columns = usize::from(area.width);
|
||||
let alignment = match line.line.alignment.unwrap_or(Alignment::Left) {
|
||||
Alignment::Left => 0,
|
||||
Alignment::Center => (columns / 2).saturating_sub(width / 2),
|
||||
Alignment::Right => columns.saturating_sub(width),
|
||||
};
|
||||
let (alignment, end) = if width > columns {
|
||||
let end = (0..area.width)
|
||||
.rfind(|x| !buf[(area.x + x, area.y)].symbol().trim().is_empty())
|
||||
.map_or(/*default*/ 0, |x| {
|
||||
usize::from(x + buf[(area.x + x, area.y)].cell_width())
|
||||
});
|
||||
let start = if line.line.alignment.unwrap_or(Alignment::Left) == Alignment::Left {
|
||||
0
|
||||
} else {
|
||||
(0..area.width)
|
||||
.find(|x| !buf[(area.x + x, area.y)].symbol().trim().is_empty())
|
||||
.map_or(/*default*/ 0, usize::from)
|
||||
};
|
||||
(start, end)
|
||||
} else {
|
||||
(alignment, alignment + width)
|
||||
};
|
||||
for column in alignment + prefix..end.min(columns) {
|
||||
buf[(area.x + column as u16, area.y)]
|
||||
.set_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Viewport behavior and cache lifetime across scrolling, pagination, and redraws.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(crate::history_cell::PlainHistoryCell::new(
|
||||
text.into()
|
||||
.lines()
|
||||
.map(|line| line.to_owned().into())
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn render(
|
||||
view: &mut TranscriptView,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Buffer {
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
view.render(area, &mut buffer, cells, /*highlight*/ None);
|
||||
buffer
|
||||
}
|
||||
|
||||
fn text(buffer: &Buffer) -> String {
|
||||
(buffer.area.top()..buffer.area.bottom())
|
||||
.map(|y| {
|
||||
(buffer.area.left()..buffer.area.right())
|
||||
.map(|x| buffer[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_owned()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MutableHistoryCell {
|
||||
version: Arc<AtomicUsize>,
|
||||
renders: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl HistoryCell for MutableHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.renders.fetch_add(/*val*/ 1, Ordering::Relaxed);
|
||||
let version = self.version.load(Ordering::Relaxed);
|
||||
(0..8)
|
||||
.map(|row| Line::from(format!("version {version} row {row}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
self.display_lines(/*width*/ 80)
|
||||
}
|
||||
|
||||
fn has_stable_transcript_height(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_survives_prepend_and_new_output_then_returns_to_latest() {
|
||||
let mut cells = vec![cell("older\nline two"), cell("current\nlast line")];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells, /*width*/ 20, /*height*/ 3);
|
||||
view.scroll(&cells, /*rows*/ -2);
|
||||
let before = text(&render(
|
||||
&mut view, &cells, /*width*/ 20, /*height*/ 3,
|
||||
));
|
||||
cells.insert(/*index*/ 0, cell("loaded from history"));
|
||||
view.history_changed(&cells);
|
||||
cells.push(cell("new output"));
|
||||
assert_eq!(
|
||||
text(&render(
|
||||
&mut view, &cells, /*width*/ 20, /*height*/ 3
|
||||
)),
|
||||
before
|
||||
);
|
||||
cells.remove(/*index*/ 0);
|
||||
view.history_changed(&cells);
|
||||
assert_eq!(
|
||||
text(&render(
|
||||
&mut view, &cells, /*width*/ 20, /*height*/ 3
|
||||
)),
|
||||
before
|
||||
);
|
||||
view.jump_to_latest();
|
||||
insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 3)), @"
|
||||
last line
|
||||
|
||||
new output
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepending_history_updates_pagination_position_before_the_next_frame() {
|
||||
let mut cells = vec![cell("current first"), cell("current last")];
|
||||
let mut view = TranscriptView::default();
|
||||
view.jump_to_entry(&cells, /*index*/ 0);
|
||||
assert!(view.near_start(&cells));
|
||||
render(&mut view, &cells, /*width*/ 20, /*height*/ 3);
|
||||
cells.remove(/*index*/ 0);
|
||||
view.history_changed(&cells);
|
||||
cells.insert(/*index*/ 0, cell("earlier\n".repeat(100)));
|
||||
view.history_changed(&cells);
|
||||
assert!(!view.near_start(&cells));
|
||||
cells.remove(/*index*/ 0);
|
||||
view.history_changed(&cells);
|
||||
let earlier = (0..100)
|
||||
.map(|index| cell(format!("earlier {index}")))
|
||||
.collect::<Vec<_>>();
|
||||
cells.splice(0..0, earlier);
|
||||
view.history_changed(&cells);
|
||||
assert!(!view.near_start(&cells));
|
||||
let Position::Reading(anchor) = view.position else {
|
||||
panic!("reading anchor");
|
||||
};
|
||||
assert_eq!(
|
||||
(anchor.key, anchor.index),
|
||||
(EntryKey::cell(&cells[100]), 100)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_row_scrolling_crosses_an_entry_separator_in_both_directions() {
|
||||
let cells = vec![cell("first"), cell("second")];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells, /*width*/ 20, /*height*/ 1);
|
||||
let mut frames = Vec::new();
|
||||
for rows in [-1, -1, 1, 1] {
|
||||
view.scroll(&cells, rows);
|
||||
frames.push(text(&render(
|
||||
&mut view, &cells, /*width*/ 20, /*height*/ 1,
|
||||
)));
|
||||
}
|
||||
assert_eq!(frames, ["", "first", "", "second"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_and_consolidation_clamp_the_row_within_the_same_entry() {
|
||||
let mut cells = vec![
|
||||
cell("older"),
|
||||
cell("alpha beta gamma delta epsilon zeta eta theta"),
|
||||
];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells, /*width*/ 10, /*height*/ 2);
|
||||
view.scroll(&cells, /*rows*/ -1);
|
||||
let Position::Reading(anchor) = view.position else {
|
||||
panic!("reading anchor");
|
||||
};
|
||||
render(&mut view, &cells, /*width*/ 8, /*height*/ 2);
|
||||
assert_eq!(view.position, Position::Reading(anchor));
|
||||
let wide = render(&mut view, &cells, /*width*/ 60, /*height*/ 2);
|
||||
assert_eq!(
|
||||
text(&wide),
|
||||
"alpha beta gamma delta epsilon zeta eta theta\n"
|
||||
);
|
||||
|
||||
let replacement = cell("final answer");
|
||||
view.replace_range(&cells, 0..2, &replacement);
|
||||
cells.splice(0..2, [replacement]);
|
||||
let completed = render(&mut view, &cells, /*width*/ 60, /*height*/ 2);
|
||||
assert!(!view.is_following());
|
||||
insta::assert_snapshot!(text(&completed), @"
|
||||
final answer
|
||||
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() {
|
||||
let version = Arc::new(AtomicUsize::new(/*v*/ 1));
|
||||
let renders = Arc::new(AtomicUsize::new(/*v*/ 0));
|
||||
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(MutableHistoryCell {
|
||||
version: Arc::clone(&version),
|
||||
renders: Arc::clone(&renders),
|
||||
})];
|
||||
let mut view = TranscriptView::default();
|
||||
let first = render(&mut view, &cells, /*width*/ 24, /*height*/ 5);
|
||||
assert_eq!(renders.load(Ordering::Relaxed), 1);
|
||||
version.store(/*val*/ 2, Ordering::Relaxed);
|
||||
let second = render(&mut view, &cells, /*width*/ 24, /*height*/ 5);
|
||||
assert_eq!(renders.load(Ordering::Relaxed), 2);
|
||||
assert!(text(&first).contains("version 1 row 3"));
|
||||
insta::assert_snapshot!(text(&second), @"
|
||||
version 2 row 3
|
||||
version 2 row 4
|
||||
version 2 row 5
|
||||
version 2 row 6
|
||||
version 2 row 7
|
||||
");
|
||||
}
|
||||
Reference in New Issue
Block a user