in codex-rs/tui/src/conversation_history_widget.rs [238:387]
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let (title, border_style) = if self.has_input_focus {
(
"Messages (↑/↓ or j/k = line, b/space = page)",
Style::default().fg(Color::LightYellow),
)
} else {
("Messages (tab to focus)", Style::default().dim())
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style);
// ------------------------------------------------------------------
// Build a *window* into the history instead of cloning the entire
// history into a brand‑new Vec every time we are asked to render.
//
// There can be an unbounded number of `Line` objects in the history,
// but the terminal will only ever display `height` of them at once.
// By materialising only the `height` lines that are scrolled into
// view we avoid the potentially expensive clone of the full
// conversation every frame.
// ------------------------------------------------------------------
// Compute the inner area that will be available for the list after
// the surrounding `Block` is drawn.
let inner = block.inner(area);
let viewport_height = inner.height as usize;
// Collect the lines that will actually be visible in the viewport
// while keeping track of the total number of lines so the scrollbar
// stays correct.
let num_lines: usize = self.history.iter().map(|c| c.lines().len()).sum();
let max_scroll = num_lines.saturating_sub(viewport_height) + 1;
let scroll_pos = if self.scroll_position == usize::MAX {
max_scroll
} else {
self.scroll_position.min(max_scroll)
};
let mut visible_lines: Vec<Line<'static>> = Vec::with_capacity(viewport_height);
if self.scroll_position == usize::MAX {
// Stick‑to‑bottom mode: walk the history backwards and keep the
// most recent `height` lines. This touches at most `height`
// lines regardless of how large the conversation grows.
'outer_rev: for cell in self.history.iter().rev() {
for line in cell.lines().iter().rev() {
visible_lines.push(line.clone());
if visible_lines.len() == viewport_height {
break 'outer_rev;
}
}
}
visible_lines.reverse();
} else {
// Arbitrary scroll position. Skip lines until we reach the
// desired offset, then emit the next `height` lines.
let start_line = scroll_pos;
let mut current_index = 0usize;
'outer_fwd: for cell in &self.history {
for line in cell.lines() {
if current_index >= start_line {
visible_lines.push(line.clone());
if visible_lines.len() == viewport_height {
break 'outer_fwd;
}
}
current_index += 1;
}
}
}
// We track the number of lines in the struct so can let the user take over from
// something other than usize::MAX when they start scrolling up. This could be
// removed once we have the vec<Lines> in self.
self.num_rendered_lines.set(num_lines);
self.last_viewport_height.set(viewport_height);
// The widget takes care of drawing the `block` and computing its own
// inner area, so we render it over the full `area`.
// We *manually* sliced the set of `visible_lines` to fit within the
// viewport above, so there is no need to ask the `Paragraph` widget
// to apply an additional scroll offset. Doing so would cause the
// content to be shifted *twice* – once by our own logic and then a
// second time by the widget – which manifested as the entire block
// drifting off‑screen when the user attempted to scroll.
let paragraph = Paragraph::new(visible_lines)
.block(block)
.wrap(Wrap { trim: false });
paragraph.render(area, buf);
let needs_scrollbar = num_lines > viewport_height;
if needs_scrollbar {
let mut scroll_state = ScrollbarState::default()
// TODO(ragona):
// I don't totally understand this, but it appears to work exactly as expected
// if we set the content length as the lines minus the height. Maybe I was supposed
// to use viewport_content_length or something, but this works and I'm backing away.
.content_length(num_lines.saturating_sub(viewport_height))
.position(scroll_pos);
// Choose a thumb colour that stands out only when this pane has focus so that the
// user’s attention is naturally drawn to the active viewport. When unfocused we show
// a low‑contrast thumb so the scrollbar fades into the background without becoming
// invisible.
let thumb_style = if self.has_input_focus {
Style::reset().fg(Color::LightYellow)
} else {
Style::reset().fg(Color::Gray)
};
StatefulWidget::render(
// By default the Scrollbar widget inherits the style that was already present
// in the underlying buffer cells. That means if a coloured line (for example a
// background task notification that we render in blue) happens to be underneath
// the scrollbar, the track and thumb adopt that colour and the scrollbar appears
// to “change colour”. Explicitly setting the *track* and *thumb* styles ensures
// we always draw the scrollbar with the same palette regardless of what content
// is behind it.
//
// N.B. Only the *foreground* colour matters here because the scrollbar symbols
// themselves are filled‐in block glyphs that completely overwrite the prior
// character cells. We therefore leave the background at its default value so it
// blends nicely with the surrounding `Block`.
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"))
.begin_style(Style::reset().fg(Color::DarkGray))
.end_style(Style::reset().fg(Color::DarkGray))
// A solid thumb so that we can colour it distinctly from the track.
.thumb_symbol("█")
// Apply the dynamic thumb colour computed above. We still start from
// Style::reset() to clear any inherited modifiers.
.thumb_style(thumb_style)
// Thin vertical line for the track.
.track_symbol(Some("│"))
.track_style(Style::reset().fg(Color::DarkGray)),
inner,
buf,
&mut scroll_state,
);
}
}