Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1244 lines
51 KiB
Rust
1244 lines
51 KiB
Rust
//! ScrollbackPane widget - the main conversation display.
|
||
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Style;
|
||
use ratatui::widgets::StatefulWidget;
|
||
use std::ops::Range;
|
||
|
||
use crate::render::SafeBuf;
|
||
use crate::render::color::{blend_color, fade_region};
|
||
use crate::scrollback::block::{BlockContent, RenderBlock};
|
||
use crate::scrollback::entry::ScrollbackEntry;
|
||
use crate::scrollback::layout::HorizontalLayout;
|
||
use crate::scrollback::render::{ScratchBuffer, render_scrolled_entries_with_selection_boundaries};
|
||
use crate::scrollback::selection::{RenderOutput, ScrollInfo, SelectionBox};
|
||
use crate::scrollback::state::{ScrollbackState, ViewMode};
|
||
use crate::scrollback::sticky::{PromptDescriptor, StickyHeaderLayout, compute_sticky_layout};
|
||
use crate::scrollback::text_selection::ResolvedSelectionBoundaries;
|
||
use crate::scrollback::types::{BlockContext, DisplayMode};
|
||
use crate::theme::Theme;
|
||
|
||
/// Scrollback pane widget.
|
||
///
|
||
/// Displays conversation entries with optional pinned header for the current turn's prompt.
|
||
///
|
||
/// # Scratch Buffers
|
||
/// For efficiency, scratch buffers should be owned by the caller and reused across frames.
|
||
/// Use `render_with_scratch()` for optimal performance. The `StatefulWidget::render()` impl
|
||
/// creates a temporary scratch buffer for API compatibility but is less efficient.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct ScrollbackPane {
|
||
pub is_active: bool,
|
||
pub mouse_pos: Option<(u16, u16)>,
|
||
pub dim_from_entry: Option<usize>,
|
||
/// Index of the entry currently under the mouse cursor (post-`hit_test`).
|
||
/// Used to paint a hover bg + swap the indicator for tool-call entries.
|
||
/// Hover painting still fires when `is_active` is false — hover is a
|
||
/// mouse affordance, not a focus indicator.
|
||
pub hovered_entry: Option<usize>,
|
||
/// When set, every visible content row is post-painted to invert cells
|
||
/// matching this regex (scrollback search). `None` disables highlighting.
|
||
pub search_highlight: Option<regex::Regex>,
|
||
/// Absolute paths of media generated in this transcript, used to resolve the
|
||
/// short relative paths the model prints (`images/1.jpg`) into clickable
|
||
/// `file://` links. Empty disables relative-path resolution.
|
||
pub media_paths: Vec<std::path::PathBuf>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub(crate) struct RenderOutputWithSelectionBoundaries {
|
||
pub(crate) output: RenderOutput,
|
||
pub(crate) selection_boundaries: ResolvedSelectionBoundaries,
|
||
}
|
||
|
||
impl ScrollbackPane {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
is_active: false,
|
||
mouse_pos: None,
|
||
dim_from_entry: None,
|
||
hovered_entry: None,
|
||
search_highlight: None,
|
||
media_paths: Vec::new(),
|
||
}
|
||
}
|
||
|
||
pub fn active(mut self, active: bool) -> Self {
|
||
self.is_active = active;
|
||
self
|
||
}
|
||
|
||
/// Set mouse position for timestamp hover detection.
|
||
pub fn with_mouse_pos(mut self, pos: (u16, u16)) -> Self {
|
||
self.mouse_pos = Some(pos);
|
||
self
|
||
}
|
||
|
||
pub fn with_dim_from(mut self, dim_from: Option<usize>) -> Self {
|
||
self.dim_from_entry = dim_from;
|
||
self
|
||
}
|
||
|
||
pub fn with_hovered_entry(mut self, hovered: Option<usize>) -> Self {
|
||
self.hovered_entry = hovered;
|
||
self
|
||
}
|
||
|
||
/// Set the regex whose matches are inverted on every visible content row.
|
||
pub fn with_search_highlight(mut self, re: Option<regex::Regex>) -> Self {
|
||
self.search_highlight = re;
|
||
self
|
||
}
|
||
|
||
/// Set the transcript's generated-media paths used to resolve relative
|
||
/// file-path link targets (`images/1.jpg`) into clickable `file://` links.
|
||
pub fn with_media_paths(mut self, media_paths: Vec<std::path::PathBuf>) -> Self {
|
||
self.media_paths = media_paths;
|
||
self
|
||
}
|
||
}
|
||
|
||
impl StatefulWidget for ScrollbackPane {
|
||
type State = ScrollbackState;
|
||
|
||
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
|
||
// Create a temporary scratch buffer for API compatibility.
|
||
// For better performance, use render_with_scratch() with a reusable scratch buffer.
|
||
let mut scratch = ScratchBuffer::new();
|
||
self.render_with_scratch(area, buf, state, &mut scratch);
|
||
}
|
||
}
|
||
|
||
impl ScrollbackPane {
|
||
/// Render the scrollback pane with an externally-owned scratch buffer.
|
||
///
|
||
/// This is the preferred method for rendering - the scratch buffer should be
|
||
/// created once and reused across frames to avoid allocations.
|
||
///
|
||
/// # TODO: Make render pure (Phase 2-5)
|
||
///
|
||
/// After implementing `prepare_layout()` in ScrollbackState, this method should
|
||
/// take `state: &ScrollbackState` (immutable) instead of `&mut ScrollbackState`.
|
||
/// All state mutations will move to `prepare_layout()`, making render truly pure.
|
||
///
|
||
/// # TODO: Consider StatefulWidget-like pattern
|
||
///
|
||
/// Once state is immutable, we could have a pattern like:
|
||
/// ```ignore
|
||
/// trait PureWidget {
|
||
/// type ReadState;
|
||
/// type ScratchState;
|
||
/// fn render(&self, area: Rect, buf: &mut Buffer,
|
||
/// state: &Self::ReadState, scratch: &mut Self::ScratchState);
|
||
/// }
|
||
/// ```
|
||
/// This separates read-only state from mutable scratch/working memory.
|
||
///
|
||
/// Returns RenderOutput containing elements to render in a post-pass (e.g., selection box, scroll info).
|
||
pub fn render_with_scratch(
|
||
self,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
state: &ScrollbackState,
|
||
scratch: &mut ScratchBuffer,
|
||
) -> RenderOutput {
|
||
self.render_with_scratch_and_selection_boundaries(area, buf, state, scratch)
|
||
.output
|
||
}
|
||
|
||
pub(crate) fn render_with_scratch_and_selection_boundaries(
|
||
self,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
state: &ScrollbackState,
|
||
scratch: &mut ScratchBuffer,
|
||
) -> RenderOutputWithSelectionBoundaries {
|
||
if area.width == 0 || area.height == 0 {
|
||
return RenderOutputWithSelectionBoundaries::default();
|
||
}
|
||
|
||
let (scroll_offset, viewport_height, total_height) = state.scroll_info();
|
||
let scroll_info = ScrollInfo {
|
||
scroll_offset,
|
||
viewport_height,
|
||
total_height,
|
||
};
|
||
|
||
let theme = Theme::current();
|
||
|
||
// All layout preparation (viewport, heights, follow mode, cache
|
||
// invalidation) happens in state.prepare_layout(), called before
|
||
// render, keeping render as close to pure as possible.
|
||
|
||
// Scrollbar is rendered by Viewport, not here.
|
||
let mut output = match state.view_mode() {
|
||
ViewMode::SingleTurn => self.render_single_turn(area, buf, state, &theme, scratch),
|
||
ViewMode::AllTurns => {
|
||
let full_range = 0..state.len();
|
||
self.render_with_sticky_headers(area, buf, state, &theme, full_range, scratch)
|
||
}
|
||
};
|
||
|
||
// Hover affordance for tool-call entries: paint a hover bg + swap
|
||
// the bullet to a chevron when collapsed/foldable. Runs whether or
|
||
// not the scrollback owns focus — hover is a mouse signal.
|
||
self.render_tool_call_hover(buf, area, state, &theme);
|
||
|
||
output.output.scroll_info = Some(scroll_info);
|
||
output
|
||
}
|
||
|
||
/// Paint hover affordance for foldable header-style blocks
|
||
/// (`ToolCall`, `Thinking`, `BgTask`, `Subagent`).
|
||
///
|
||
/// - Skips when nothing is hovered, or when the hovered entry is the
|
||
/// currently selected entry (selection wins; avoids double-paint).
|
||
/// - Skips entries that aren't header-style — markdown / agent message
|
||
/// blocks already get the hover *border* via
|
||
/// `agent::render_entry_hover` and don't need a full bg patch.
|
||
/// - Hover bg is `blend(bg_base, bg_dark, 0.5)` so it's strictly
|
||
/// dimmer than the selection bg (`bg_dark`) across all themes.
|
||
/// - Inset matches the group-selection bg rule (skip 1 col on each
|
||
/// side unless `display.highlight_overlays_border` is set).
|
||
/// - Chevron paint is shared with the selected-entry path via
|
||
/// [`paint_expandable_indicator`]. `BgTask` / `Subagent` aren't
|
||
/// foldable so the chevron is a no-op there, but the hover bg
|
||
/// still paints to match the other collapsed tool-call rows.
|
||
fn render_tool_call_hover(
|
||
&self,
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
state: &ScrollbackState,
|
||
theme: &Theme,
|
||
) {
|
||
let Some(hover_idx) = self.hovered_entry else {
|
||
return;
|
||
};
|
||
if state.selected() == Some(hover_idx) {
|
||
return;
|
||
}
|
||
let Some(entry) = state.entry(hover_idx) else {
|
||
return;
|
||
};
|
||
if !matches!(
|
||
entry.block,
|
||
crate::scrollback::block::RenderBlock::ToolCall(_)
|
||
| crate::scrollback::block::RenderBlock::Thinking(_)
|
||
| crate::scrollback::block::RenderBlock::BgTask(_)
|
||
| crate::scrollback::block::RenderBlock::Subagent(_)
|
||
) {
|
||
return;
|
||
}
|
||
// Skip hover bg whenever the entry isn't collapsed. Once the user
|
||
// has folded the entry open (Truncated for `Execute`/`Other` while
|
||
// streaming, `Expanded` for Edit/markdown), the row already has
|
||
// line-level styling — diff green/red, stdout `bg_dark`, etc. —
|
||
// and a full bg patch either clobbers it or is redundant.
|
||
// The hover border (`render_entry_hover`) still fires to indicate
|
||
// hover; the chevron paint below is a no-op for non-collapsed
|
||
// entries anyway.
|
||
if entry.display_mode != DisplayMode::Collapsed {
|
||
return;
|
||
}
|
||
let Some((entry_area, top_clipped, _bottom_clipped)) =
|
||
state.entry_screen_area(hover_idx, area)
|
||
else {
|
||
return;
|
||
};
|
||
|
||
let display_cfg = &state.appearance().scrollback.display;
|
||
let hover_bg = blend_color(theme.bg_base, theme.bg_dark, 0.5).unwrap_or(theme.bg_dark);
|
||
let bg_style = Style::default().bg(hover_bg);
|
||
|
||
// Inset the hover bg by 1 column on each side unless the appearance
|
||
// config opts into overlaying the border, mirroring the group
|
||
// selection bg behaviour.
|
||
let (hl_x, hl_width) = if display_cfg.highlight_overlays_border {
|
||
(entry_area.x, entry_area.width)
|
||
} else {
|
||
(entry_area.x + 1, entry_area.width.saturating_sub(2))
|
||
};
|
||
|
||
if hl_width > 0 {
|
||
for y in entry_area.y..entry_area.y + entry_area.height {
|
||
if y >= area.y && y < area.y + area.height {
|
||
for x in hl_x..hl_x + hl_width {
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_style(bg_style);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Swap ◆ → › on the bullet row when the entry is foldable +
|
||
// collapsed (or running and at min fold mode). Same predicate as
|
||
// the selected-entry chevron paint. An expanded verb slot hovers ⌄
|
||
// on its header row; when the header is top-clipped off-screen the
|
||
// first visible row is member 0's, which takes the normal ›.
|
||
let verb_expanded = state
|
||
.get_cached_entry_layouts()
|
||
.and_then(|l| l.get(hover_idx))
|
||
.is_some_and(|i| i.verb_group_header && i.group_collapse_header);
|
||
paint_expandable_indicator(
|
||
buf,
|
||
area,
|
||
entry_area.y,
|
||
state.appearance(),
|
||
entry,
|
||
verb_expanded && !top_clipped,
|
||
);
|
||
}
|
||
|
||
// Unified sticky header rendering for both SingleTurn and AllTurns modes.
|
||
//
|
||
// The key insight: SingleTurn is just AllTurns with a filtered range.
|
||
// Both modes use the same gradual header collapse logic via compute_sticky_layout().
|
||
//
|
||
// For SingleTurn:
|
||
// - visible_range = state.visible_entry_range() (entries 0..N of the current turn)
|
||
// - Prompt descriptors have y_virtual relative to range start
|
||
// - The first entry (prompt) becomes a sticky header when scrolled past
|
||
//
|
||
// For AllTurns:
|
||
// - visible_range = 0..state.len() (all entries)
|
||
// - Prompt descriptors have y_virtual as cumulative from entry 0
|
||
// - Multiple prompts can become sticky headers as user scrolls
|
||
|
||
/// Render in SingleTurn mode using unified sticky header logic.
|
||
fn render_single_turn(
|
||
&self,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
state: &ScrollbackState,
|
||
theme: &Theme,
|
||
scratch: &mut ScratchBuffer,
|
||
) -> RenderOutputWithSelectionBoundaries {
|
||
let visible_range = state.visible_entry_range();
|
||
self.render_with_sticky_headers(area, buf, state, theme, visible_range, scratch)
|
||
}
|
||
|
||
/// Render entries with sticky section headers.
|
||
///
|
||
/// This is the unified rendering path for both SingleTurn and AllTurns modes.
|
||
/// Prompts within the entry_range act as section headers that stick to the top
|
||
/// when scrolled past, and get pushed off by the next approaching prompt.
|
||
///
|
||
/// - For SingleTurn: entry_range = visible_entry_range() (one turn's entries)
|
||
/// - For AllTurns: entry_range = 0..len() (all entries)
|
||
///
|
||
/// Returns RenderOutput containing selection box (if any) to be rendered after.
|
||
fn render_with_sticky_headers(
|
||
&self,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
state: &ScrollbackState,
|
||
theme: &Theme,
|
||
entry_range: Range<usize>,
|
||
scratch: &mut ScratchBuffer,
|
||
) -> RenderOutputWithSelectionBoundaries {
|
||
let layout_cfg = &state.appearance().scrollback.layout;
|
||
let layout = HorizontalLayout::new(area, layout_cfg);
|
||
let entry_content_width = layout.entry_content_area().width;
|
||
|
||
// For SingleTurn, this gives us relative y_virtual coordinates.
|
||
// For AllTurns, this is equivalent to the full range.
|
||
let prompts = self.build_prompt_descriptors_for_range(
|
||
state,
|
||
entry_content_width,
|
||
theme,
|
||
entry_range.clone(),
|
||
);
|
||
|
||
// Compute sticky header layout (disabled in compact mode).
|
||
let use_sticky = state.appearance().scrollback.display.sticky_headers
|
||
&& !state.appearance().prompt.compact;
|
||
let sticky = if use_sticky {
|
||
compute_sticky_layout(state.scroll_offset(), area.height, &prompts)
|
||
} else {
|
||
StickyHeaderLayout::default()
|
||
};
|
||
|
||
// The sticky layout encapsulates all 1D coordinate math.
|
||
// We just ask it for screen positions and scroll offsets.
|
||
let header_height = sticky.header_screen_rows();
|
||
|
||
let content_area = if header_height > 0 && header_height < area.height {
|
||
Rect {
|
||
x: area.x,
|
||
y: area.y + header_height,
|
||
width: area.width,
|
||
height: area.height.saturating_sub(header_height),
|
||
}
|
||
} else if header_height >= area.height {
|
||
// Header takes entire viewport (edge case).
|
||
// Clamp y to the last valid row of area so it stays within the
|
||
// buffer, even though height=0 means nothing will render here.
|
||
Rect {
|
||
x: area.x,
|
||
y: area.y + area.height.saturating_sub(1),
|
||
width: area.width,
|
||
height: 0,
|
||
}
|
||
} else {
|
||
area
|
||
};
|
||
|
||
let mut pushed_header_selection_box: Option<SelectionBox> = None;
|
||
if let Some(ref pushed) = sticky.pushed {
|
||
let visible_height = pushed.visible_height();
|
||
if visible_height > 0 {
|
||
let screen_row = sticky.pushed_screen_row().unwrap_or(0);
|
||
let header_area = Rect {
|
||
x: area.x,
|
||
y: area.y + screen_row,
|
||
width: area.width,
|
||
height: visible_height,
|
||
};
|
||
self.render_sticky_header(
|
||
buf,
|
||
header_area,
|
||
state,
|
||
pushed.entry_idx,
|
||
theme,
|
||
pushed.render_height,
|
||
pushed.clip_top,
|
||
scratch,
|
||
self.is_active && state.selected() == Some(pushed.entry_idx),
|
||
self.mouse_pos,
|
||
);
|
||
|
||
// Fade out the pushed header as it's being pushed off.
|
||
// The fade makes the transition smoother visually.
|
||
// opacity = visible_rows / (full_height + 1)
|
||
// So even a fully visible pushed header (clip_top=0) starts at 80% for 4-row header.
|
||
let opacity =
|
||
visible_height as f32 / (pushed.render_height.saturating_add(1)) as f32;
|
||
fade_region(buf, header_area, theme.bg_base, opacity);
|
||
|
||
if self.is_active && state.selected() == Some(pushed.entry_idx) {
|
||
let layout = HorizontalLayout::new(header_area, layout_cfg);
|
||
let selection_area = layout.selection_area();
|
||
|
||
// Selection border fades with content.
|
||
// If needed, use opacity.max(0.5) to enforce a minimum visibility floor.
|
||
let border_color = blend_color(theme.bg_base, theme.selection_border, opacity)
|
||
.unwrap_or(theme.selection_border);
|
||
|
||
// Top is clipped only when content is actually being clipped off (clip_top > 0)
|
||
// When clip_top == 0, the full header is visible (just fading), so show full border
|
||
let top_clipped = pushed.clip_top > 0;
|
||
|
||
let sel_box =
|
||
SelectionBox::new(selection_area, Style::default().fg(border_color))
|
||
.with_top_clipped(top_clipped)
|
||
.with_bottom_clipped(false);
|
||
|
||
pushed_header_selection_box = Some(sel_box);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render pinned header (if any) - the main sticky header
|
||
let mut pinned_header_selection: Option<(Rect, usize)> = None;
|
||
if let Some(ref pinned) = sticky.pinned {
|
||
let visible_height = pinned.visible_height();
|
||
if visible_height > 0 {
|
||
let screen_row = sticky.pinned_screen_row().unwrap_or(0);
|
||
let header_area = Rect {
|
||
x: area.x,
|
||
y: area.y + screen_row,
|
||
width: area.width,
|
||
height: visible_height,
|
||
};
|
||
self.render_sticky_header(
|
||
buf,
|
||
header_area,
|
||
state,
|
||
pinned.entry_idx,
|
||
theme,
|
||
pinned.render_height,
|
||
pinned.clip_top,
|
||
scratch,
|
||
self.is_active && state.selected() == Some(pinned.entry_idx),
|
||
self.mouse_pos,
|
||
);
|
||
|
||
// For selection, use HorizontalLayout to match content entries' selection width
|
||
let layout = HorizontalLayout::new(header_area, layout_cfg);
|
||
let selection_area = layout.selection_area();
|
||
pinned_header_selection = Some((selection_area, pinned.entry_idx));
|
||
}
|
||
}
|
||
|
||
// NOTE: The gap row is part of the header area height.
|
||
// It should naturally be empty since we don't render anything there.
|
||
|
||
let mut pinned_header_selection_box: Option<SelectionBox> = None;
|
||
if self.is_active
|
||
&& let Some((selection_area, entry_idx)) = pinned_header_selection
|
||
&& state.selected() == Some(entry_idx)
|
||
{
|
||
// Selection box for sticky header:
|
||
// - y_top = selection_area.y (first row of selection content)
|
||
// - y_bottom = selection_area.y + selection_area.height - 1 (last row)
|
||
// - top_clipped = false if there's a gap row above for corners
|
||
// - bottom_clipped = false since there's always a gap row between header and content
|
||
let screen_row = sticky.pinned_screen_row().unwrap_or(0);
|
||
|
||
// Check if there's room for top corners
|
||
let top_clipped = screen_row == 0 && area.y == 0;
|
||
|
||
let sel_box =
|
||
SelectionBox::new(selection_area, Style::default().fg(theme.selection_border))
|
||
.with_top_clipped(top_clipped)
|
||
.with_bottom_clipped(false);
|
||
|
||
pinned_header_selection_box = Some(sel_box);
|
||
}
|
||
|
||
if content_area.height > 0 {
|
||
let visible_range = entry_range.clone();
|
||
|
||
// Use the sticky layout's scroll_for_content() which maintains bottom line continuity.
|
||
let scroll_for_content = sticky.scroll_for_content(state.scroll_offset());
|
||
|
||
// Get the entry index shown in the pinned header (to avoid duplicate selection)
|
||
let pinned_entry_idx = sticky.pinned_entry_idx();
|
||
|
||
let mut content_output = self.render_content(
|
||
area,
|
||
content_area,
|
||
buf,
|
||
state,
|
||
theme,
|
||
visible_range,
|
||
scroll_for_content,
|
||
0,
|
||
// In AllTurns mode, we may have already computed selection for pinned header.
|
||
// Pass None to avoid duplicate selection box computation in render_content.
|
||
None,
|
||
pinned_entry_idx,
|
||
pinned_header_selection_box.is_some(),
|
||
scratch,
|
||
);
|
||
|
||
// Return the selection box - prioritize pushed > pinned > content
|
||
let content_selection_box = content_output.output.selection_box.take();
|
||
content_output.output.selection_box = pushed_header_selection_box
|
||
.or(pinned_header_selection_box)
|
||
.or(content_selection_box);
|
||
content_output
|
||
} else {
|
||
// Return header selection if we have one (pushed > pinned)
|
||
RenderOutputWithSelectionBoundaries {
|
||
output: RenderOutput {
|
||
selection_box: pushed_header_selection_box.or(pinned_header_selection_box),
|
||
..Default::default()
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build prompt descriptors for a range of entries.
|
||
///
|
||
/// The y_virtual coordinates are relative to the range start, not absolute.
|
||
/// This allows the same sticky layout logic to work for both:
|
||
/// - SingleTurn: range = visible_entry_range() (one turn's entries)
|
||
/// - AllTurns: range = 0..len() (all entries)
|
||
///
|
||
/// NOTE: Uses cached data from prepare_layout(); heights are not
|
||
/// recomputed here, they come from the LayoutCache.
|
||
fn build_prompt_descriptors_for_range(
|
||
&self,
|
||
state: &ScrollbackState,
|
||
// Kept for API compat; unused since heights come from cache.
|
||
_entry_content_width: u16,
|
||
_theme: &Theme,
|
||
entry_range: Range<usize>,
|
||
) -> Vec<PromptDescriptor> {
|
||
let cached_descriptors = state
|
||
.get_cached_prompt_descriptors()
|
||
.expect("layout cache must be valid - was prepare_layout() called?");
|
||
|
||
let cached_virtual_y = state
|
||
.get_cached_virtual_y()
|
||
.expect("layout cache must be valid - was prepare_layout() called?");
|
||
|
||
// Get the y_offset of the first entry in the range
|
||
// All y_virtual values will be adjusted relative to this
|
||
let y_offset = cached_virtual_y
|
||
.get(entry_range.start)
|
||
.copied()
|
||
.unwrap_or(0);
|
||
|
||
cached_descriptors
|
||
.iter()
|
||
.filter(|p| entry_range.contains(&p.entry_idx))
|
||
.map(|p| PromptDescriptor {
|
||
entry_idx: p.entry_idx,
|
||
y_virtual: p.y_virtual.saturating_sub(y_offset),
|
||
full_height: p.full_height,
|
||
min_height: p.min_height,
|
||
sticky: p.sticky,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Render a sticky header (pushed or pinned).
|
||
///
|
||
/// - `render_height`: Total height budget for the block (including vpads)
|
||
/// - `clip_top`: If > 0, clips rendered output from top (for push effect)
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_sticky_header(
|
||
&self,
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
state: &ScrollbackState,
|
||
entry_idx: usize,
|
||
theme: &Theme,
|
||
render_height: u16,
|
||
clip_top: u16,
|
||
scratch: &mut ScratchBuffer,
|
||
is_selected: bool,
|
||
mouse_pos: Option<(u16, u16)>,
|
||
) {
|
||
let appearance = state.appearance();
|
||
|
||
let Some(entry) = state.entry(entry_idx) else {
|
||
return;
|
||
};
|
||
|
||
let layout = HorizontalLayout::new(area, &appearance.scrollback.layout);
|
||
|
||
// The block adds vpad (2 rows) if has_vpad is true.
|
||
let cwd = state.cwd();
|
||
let has_vpad = entry
|
||
.block
|
||
.has_vpad(&entry.context(area.width, appearance, cwd));
|
||
let vpad_rows = if has_vpad { 2 } else { 0 };
|
||
let content_lines = render_height.saturating_sub(vpad_rows);
|
||
|
||
// User prompts use their actual display mode so collapsed prompts
|
||
// stay truncated (3 lines + ellipsis) in sticky headers.
|
||
// Other blocks use Expanded + max_lines budget as before.
|
||
let mode = if entry.block.is_user_prompt() {
|
||
entry.display_mode()
|
||
} else {
|
||
DisplayMode::Expanded
|
||
};
|
||
|
||
// When timestamps are shown on message blocks, reserve right margin in the
|
||
// block's content width so wrapped text doesn't collide with the overlaid
|
||
// timestamp (matches behavior in EntryRenderer for normal content).
|
||
let ts_reserved = if appearance.show_timestamps
|
||
&& matches!(
|
||
&entry.block,
|
||
RenderBlock::UserPrompt(_) | RenderBlock::AgentMessage(_) | RenderBlock::Btw(_)
|
||
) {
|
||
10
|
||
} else {
|
||
0
|
||
};
|
||
let content_width_for_block = layout.content_width().saturating_sub(ts_reserved);
|
||
|
||
let ctx = entry.context_with_mode_and_budget(
|
||
content_width_for_block,
|
||
mode,
|
||
content_lines,
|
||
appearance,
|
||
is_selected,
|
||
cwd,
|
||
);
|
||
|
||
if clip_top > 0 {
|
||
// For pushed headers being pushed OFF screen:
|
||
// - The TOP rows disappear first (pushed up, out of view)
|
||
// - The BOTTOM rows stay visible longest
|
||
//
|
||
// We render the full header to a scratch buffer, then copy only
|
||
// the bottom (visible) rows to the actual buffer.
|
||
|
||
let visible_height = render_height.saturating_sub(clip_top);
|
||
|
||
if visible_height == 0 {
|
||
return;
|
||
}
|
||
|
||
// Use reusable scratch buffer (avoids allocation per frame)
|
||
let scratch_buf = scratch.prepared(area.width, render_height);
|
||
let scratch_area = Rect::new(0, 0, area.width, render_height);
|
||
|
||
Self::render_entry_with_ctx_static(
|
||
entry,
|
||
&ctx,
|
||
theme,
|
||
scratch_area,
|
||
scratch_buf,
|
||
mouse_pos,
|
||
);
|
||
|
||
for dy in 0..visible_height {
|
||
let src_y = clip_top + dy;
|
||
let dst_y = area.y + dy;
|
||
for dx in 0..area.width {
|
||
if let Some(src_cell) = scratch_buf.cell((dx, src_y))
|
||
&& let Some(dst_cell) = buf.cell_mut((area.x + dx, dst_y))
|
||
{
|
||
*dst_cell = src_cell.clone();
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// No clipping needed - render directly with max_lines
|
||
Self::render_entry_with_ctx_static(entry, &ctx, theme, area, buf, mouse_pos);
|
||
}
|
||
}
|
||
|
||
/// Render an entry with a specific BlockContext (for max_lines support).
|
||
/// Static method to avoid borrow issues.
|
||
///
|
||
/// `mouse_pos` is forwarded for timestamp hover expansion in sticky headers.
|
||
fn render_entry_with_ctx_static(
|
||
entry: &ScrollbackEntry,
|
||
ctx: &BlockContext,
|
||
theme: &Theme,
|
||
area: Rect,
|
||
buf: &mut Buffer,
|
||
mouse_pos: Option<(u16, u16)>,
|
||
) {
|
||
use crate::scrollback::types::BlockBackground;
|
||
|
||
let layout = HorizontalLayout::new(area, &ctx.appearance.scrollback.layout);
|
||
|
||
// Use the actual content area (not entry_content_area which includes accent)
|
||
let content_area = layout.content;
|
||
let left_pad = layout.left_padding;
|
||
let right_pad = layout.right_padding;
|
||
|
||
let output = entry.block.output(ctx);
|
||
let block_has_vpad = entry.block.has_vpad(ctx);
|
||
|
||
let accent_has_bg = entry.block.accent_background(ctx);
|
||
let block_bg = entry.block.background(ctx);
|
||
|
||
let bg_color = match block_bg {
|
||
BlockBackground::None => None,
|
||
BlockBackground::Light => Some(theme.bg_light),
|
||
BlockBackground::Dark => Some(theme.bg_dark),
|
||
};
|
||
|
||
// Only use vpad if there's enough room for vpad + at least 1 content line.
|
||
// Need at least 3 rows: vpad_top (1) + content (1) + vpad_bottom (1)
|
||
// If less space, skip vpad to prioritize content.
|
||
let use_vpad = block_has_vpad && content_area.height >= 3;
|
||
|
||
let content_height = output.len() as u16;
|
||
let total_height = content_height + if use_vpad { 2 } else { 0 };
|
||
|
||
// Fill the entire entry area with block background (if any)
|
||
// This includes vpad rows, content rows, and padding columns
|
||
if let Some(bg) = bg_color {
|
||
let bg_style = ratatui::style::Style::default().bg(bg);
|
||
let fill_height = total_height.min(area.height);
|
||
|
||
for y in content_area.y..content_area.y + fill_height {
|
||
for x in left_pad.x..left_pad.x + left_pad.width {
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_style(bg_style);
|
||
}
|
||
}
|
||
for x in content_area.x..content_area.x + content_area.width {
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_style(bg_style);
|
||
}
|
||
}
|
||
for x in right_pad.x..right_pad.x + right_pad.width {
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_style(bg_style);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render vpad top if needed (skip 1 row)
|
||
let mut y = content_area.y;
|
||
if use_vpad {
|
||
y += 1;
|
||
}
|
||
|
||
// Render content lines in the actual content area (after left padding)
|
||
for line in &output.lines {
|
||
if y >= content_area.y + content_area.height {
|
||
break;
|
||
}
|
||
buf.set_line_safe(content_area.x, y, &line.content, content_area.width);
|
||
y += 1;
|
||
}
|
||
|
||
// Overlay timestamp on the first content line for message blocks in sticky
|
||
// headers (pinned/pushed user prompts, agent messages, etc.). Mirrors the
|
||
// overlay in EntryRenderer but adapted for the header render path + clip
|
||
// (scratch buffer) case. Hover expansion works for pinned (absolute coords);
|
||
// pushed headers always get the short format.
|
||
if ctx.appearance.show_timestamps
|
||
&& !output.lines.is_empty()
|
||
&& matches!(
|
||
&entry.block,
|
||
RenderBlock::UserPrompt(_) | RenderBlock::AgentMessage(_) | RenderBlock::Btw(_)
|
||
)
|
||
&& let Some(ts) = entry.created_at
|
||
{
|
||
let first_content_y = content_area.y + if use_vpad { 1 } else { 0 };
|
||
let ts_hovered = mouse_pos.is_some_and(|(mx, my)| {
|
||
my == first_content_y
|
||
&& mx >= content_area.x + content_area.width.saturating_sub(10)
|
||
&& mx < content_area.x + content_area.width
|
||
});
|
||
let ts_str = if ts_hovered {
|
||
ts.format(" %H:%M:%S | %b %d").to_string()
|
||
} else {
|
||
ts.format(" %-I:%M %p").to_string()
|
||
};
|
||
let ts_width = ts_str.len() as u16;
|
||
if content_area.width > ts_width + 1
|
||
&& first_content_y < content_area.y + content_area.height
|
||
{
|
||
let ts_x = content_area.x + content_area.width - ts_width;
|
||
let ts_style = Style::default().fg(theme.gray);
|
||
buf.set_string_safe(ts_x, first_content_y, &ts_str, ts_style);
|
||
}
|
||
}
|
||
|
||
// vpad bottom is just empty space - no need to track y further
|
||
|
||
// Draw accent line if entry has one, otherwise clear the accent column
|
||
// so stale content from previous frames doesn't bleed through.
|
||
if let Some(accent) = entry.block.accent(ctx) {
|
||
let color = accent.color;
|
||
let accent_area = layout.accent;
|
||
|
||
let accent_bg = if accent_has_bg {
|
||
match block_bg {
|
||
BlockBackground::None => theme.bg_base,
|
||
BlockBackground::Light => theme.bg_light,
|
||
BlockBackground::Dark => theme.bg_dark,
|
||
}
|
||
} else {
|
||
theme.bg_base
|
||
};
|
||
|
||
for y in accent_area.y..accent_area.y + total_height.min(area.height) {
|
||
if let Some(cell) = buf.cell_mut((accent_area.x, y)) {
|
||
cell.set_char('┃');
|
||
cell.set_style(ratatui::style::Style::default().fg(color).bg(accent_bg));
|
||
}
|
||
}
|
||
} else {
|
||
// No accent: clear the column with the block's bg so it matches.
|
||
let accent_area = layout.accent;
|
||
let clear_bg = bg_color.unwrap_or(theme.bg_base);
|
||
let clear_style = ratatui::style::Style::default().bg(clear_bg);
|
||
for y in accent_area.y..accent_area.y + total_height.min(area.height) {
|
||
if let Some(cell) = buf.cell_mut((accent_area.x, y)) {
|
||
cell.set_char(' ');
|
||
cell.set_style(clear_style);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Render the main content area (shared between SingleTurn and AllTurns).
|
||
///
|
||
/// Returns the selection box for content entries (if any), to be rendered by the frame.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_content(
|
||
&self,
|
||
area: Rect,
|
||
content_area: Rect,
|
||
buf: &mut Buffer,
|
||
state: &ScrollbackState,
|
||
theme: &Theme,
|
||
visible_range: Range<usize>,
|
||
scroll_for_content: usize,
|
||
// Unused; total_height now comes from the cache instead.
|
||
_prompt_content_height: u16,
|
||
pinned_header_selection_area: Option<Rect>,
|
||
// Entry idx shown in header (to skip in content selection).
|
||
pinned_entry_idx: Option<usize>,
|
||
// Whether selection was already computed for the header.
|
||
header_has_selection: bool,
|
||
_scratch: &mut ScratchBuffer,
|
||
) -> RenderOutputWithSelectionBoundaries {
|
||
let all_layouts = state
|
||
.get_cached_entry_layouts()
|
||
.expect("layout cache must be valid - was prepare_layout() called?");
|
||
|
||
// O(log n) paint window: only entries that can intersect the content
|
||
// viewport. Avoids collecting/walking the full AllTurns history each frame.
|
||
// Group headers (verb or truncation) that land in the window extend the
|
||
// end of the slice through the rest of their run so the aggregated header
|
||
// labels still see off-screen members (counts/tense/failures) without
|
||
// re-collecting all history.
|
||
let (paint_range, content_y0) = state.paint_window(
|
||
visible_range.clone(),
|
||
scroll_for_content,
|
||
content_area.height as usize,
|
||
);
|
||
|
||
let entries: Vec<&ScrollbackEntry> = state.entries_in_range(paint_range.clone());
|
||
let entry_layouts_cache = all_layouts
|
||
.get(paint_range.clone())
|
||
.expect("paint_range out of bounds for cached layouts");
|
||
// paint_window keeps the window inside the visible range.
|
||
let entry_index_base = paint_range.start - visible_range.start;
|
||
|
||
// Selection / dim indices are relative to the full visible range (not the
|
||
// paint window); entry_index_base remaps slice indices inside the renderer.
|
||
let visible_start = visible_range.start;
|
||
let relative_selected = if self.is_active {
|
||
state
|
||
.selected()
|
||
.filter(|&s| visible_range.contains(&s))
|
||
.map(|s| s - visible_start)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let relative_dim_from = self
|
||
.dim_from_entry
|
||
.filter(|&d| d >= visible_range.start && d < visible_range.end)
|
||
.map(|d| d - visible_start);
|
||
|
||
let rendered = render_scrolled_entries_with_selection_boundaries(
|
||
buf,
|
||
content_area,
|
||
&entries,
|
||
scroll_for_content,
|
||
relative_selected,
|
||
theme,
|
||
state.appearance(),
|
||
entry_layouts_cache,
|
||
state.current_tick(),
|
||
self.mouse_pos,
|
||
relative_dim_from,
|
||
self.search_highlight.as_ref(),
|
||
content_y0,
|
||
entry_index_base,
|
||
&self.media_paths,
|
||
Some((state.group_spans(), paint_range.start)),
|
||
state.cwd(),
|
||
);
|
||
let result = rendered.result;
|
||
let selection_boundaries = rendered.selection_boundaries;
|
||
|
||
// NOTE: total_height comes from prepare_layout() (called before render)
|
||
// and isn't updated here; result.total_height is only used locally,
|
||
// if needed for debugging.
|
||
|
||
// Capture selected entry's screen area for inline button positioning.
|
||
let selected_entry_rect = result.selected_area.as_ref().map(|s| s.area);
|
||
let selected_area = result.selected_area;
|
||
let mut content_output = RenderOutputWithSelectionBoundaries {
|
||
output: RenderOutput {
|
||
selection_model: result.selection_model,
|
||
link_overlay: result.link_overlay,
|
||
inline_media: result.inline_media,
|
||
diagram_affordances: result.diagram_affordances,
|
||
..Default::default()
|
||
},
|
||
selection_boundaries,
|
||
};
|
||
|
||
// Post-render: highlight the selected entry's rows with `bg_dark`.
|
||
//
|
||
// Fires when:
|
||
// 1. The selected entry is part of a multi-entry group (so the
|
||
// individual selected row stands out within the group), OR
|
||
// 2. The selected entry is a header-style foldable block
|
||
// (`ToolCall`, `Thinking`) — singletons get the same bg so
|
||
// selection looks consistent across grouped vs lone entries.
|
||
//
|
||
// Skipped when the selected entry isn't collapsed — once the row
|
||
// is folded open (Truncated for `Execute`/`Other` while streaming,
|
||
// `Expanded` for Edit/markdown), a full bg fill is too heavy and
|
||
// would clobber line-level styling (diff green/red, stdout
|
||
// `bg_dark`, etc.). The SelectionBox border alone is enough.
|
||
//
|
||
// Other singleton blocks (markdown messages, user prompts, etc.)
|
||
// intentionally don't get the bg patch — for big markdown blocks
|
||
// a full bg fill would be too heavy and the SelectionBox border
|
||
// alone is enough.
|
||
//
|
||
// The highlight is inset by 1 column on each side unless
|
||
// `display.highlight_overlays_border` is set, to avoid clobbering
|
||
// the SelectionBox border characters (│).
|
||
if self.is_active
|
||
&& let Some(ref selected) = selected_area
|
||
&& let Some(selected_abs) = state.selected()
|
||
{
|
||
let display_cfg = &state.appearance().scrollback.display;
|
||
let split_mode = display_cfg.group_selection_split;
|
||
let sel_range = state.group_range_of(selected_abs, split_mode);
|
||
let is_header_style_block = state.entry(selected_abs).is_some_and(|e| {
|
||
matches!(
|
||
e.block,
|
||
crate::scrollback::block::RenderBlock::ToolCall(_)
|
||
| crate::scrollback::block::RenderBlock::Thinking(_)
|
||
)
|
||
});
|
||
let is_collapsed = state
|
||
.entry(selected_abs)
|
||
.is_some_and(|e| e.display_mode == DisplayMode::Collapsed);
|
||
|
||
if (sel_range.len() > 1 || is_header_style_block) && is_collapsed {
|
||
let highlight_area = selected.area;
|
||
let bg_style = Style::default().bg(theme.bg_dark);
|
||
|
||
let (hl_x, hl_width) = if display_cfg.highlight_overlays_border {
|
||
(highlight_area.x, highlight_area.width)
|
||
} else {
|
||
(highlight_area.x + 1, highlight_area.width.saturating_sub(2))
|
||
};
|
||
|
||
if hl_width > 0 {
|
||
for y in highlight_area.y..highlight_area.y + highlight_area.height {
|
||
if y >= content_area.y && y < content_area.y + content_area.height {
|
||
for x in hl_x..hl_x + hl_width {
|
||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||
cell.set_style(bg_style);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Expandable indicator: replace the bullet character with "›" (or configured char)
|
||
// when the selected entry is foldable and at its minimum fold mode.
|
||
// Works for both grouped and singleton entries. In an expanded
|
||
// verb-group slot the selection acts as MEMBER 0, so the caret
|
||
// sits on the member row (below the header line) pointing right;
|
||
// the ⌄ group affordance lives on the hover pass instead.
|
||
if let Some(entry) = state.entry(selected_abs) {
|
||
let verb_expanded = state
|
||
.get_cached_entry_layouts()
|
||
.and_then(|l| l.get(selected_abs))
|
||
.is_some_and(|i| i.verb_group_header && i.group_collapse_header);
|
||
// Bottom-clip that cuts the member row off is handled by the
|
||
// paint fn's bounds guard (the offset row is simply skipped).
|
||
paint_expandable_indicator(
|
||
buf,
|
||
content_area,
|
||
verb_member_indicator_row(selected.area.y, verb_expanded, selected.top_clipped),
|
||
state.appearance(),
|
||
entry,
|
||
false,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Compute selection box for content entries
|
||
// Skip if:
|
||
// 1. Selection was already computed for header (header_has_selection is true), OR
|
||
// 2. The selected entry is the pinned header entry and pinned_header_selection_area is set
|
||
// (for SingleTurn mode where selection is computed later)
|
||
let skip_content_selection = header_has_selection
|
||
|| (pinned_entry_idx.is_some()
|
||
&& state.selected() == pinned_entry_idx
|
||
&& pinned_header_selection_area.is_some());
|
||
|
||
if self.is_active
|
||
&& !skip_content_selection
|
||
&& let Some(ref selected) = selected_area
|
||
&& let Some(selected_abs) = state.selected()
|
||
{
|
||
let split_mode = state.appearance().scrollback.display.group_selection_split;
|
||
let sel_range = state.group_range_of(selected_abs, split_mode);
|
||
|
||
if sel_range.len() <= 1 {
|
||
// Singleton (non-groupable, or expanded in Mode B, or lone groupable):
|
||
// use the individual entry's area directly.
|
||
let top_clipped = selected.top_clipped
|
||
|| (selected.area.y == content_area.y && content_area.y == 0);
|
||
let bottom = selected.area.y + selected.area.height;
|
||
let bottom_clipped =
|
||
selected.bottom_clipped || bottom > content_area.y + content_area.height;
|
||
|
||
let sel_box =
|
||
SelectionBox::new(selected.area, Style::default().fg(theme.selection_border))
|
||
.with_top_clipped(top_clipped)
|
||
.with_bottom_clipped(bottom_clipped);
|
||
|
||
content_output.output.selection_box = Some(sel_box);
|
||
content_output.output.selected_entry_area = selected_entry_rect;
|
||
return content_output;
|
||
}
|
||
|
||
// Multi-entry group: compute a Rect spanning from first to last entry in the range.
|
||
// Use virtual_y from cache to find screen positions.
|
||
if let Some(all_virtual_y) = state.get_cached_virtual_y()
|
||
&& let Some(all_layouts) = state.get_cached_entry_layouts()
|
||
{
|
||
// Virtual y positions (relative to visible range start)
|
||
let base_y = all_virtual_y[visible_range.start];
|
||
let group_start_vy = all_virtual_y[sel_range.start] - base_y;
|
||
let last_idx = sel_range.end - 1;
|
||
let group_end_vy =
|
||
all_virtual_y[last_idx] - base_y + all_layouts[last_idx].height as usize;
|
||
|
||
// Convert virtual y to screen y. Cumulative positions stay usize
|
||
// (tall sessions exceed u16::MAX); the screen y/height
|
||
// below are viewport-relative and provably fit in u16.
|
||
let viewport_start = scroll_for_content;
|
||
let viewport_end = scroll_for_content + content_area.height as usize;
|
||
|
||
let visible_start_vy = group_start_vy.max(viewport_start);
|
||
let visible_end_vy = group_end_vy.min(viewport_end);
|
||
|
||
if visible_start_vy < visible_end_vy {
|
||
let screen_y = content_area.y + (visible_start_vy - viewport_start) as u16;
|
||
let visible_height = (visible_end_vy - visible_start_vy) as u16;
|
||
|
||
// Use HorizontalLayout for consistent selection width
|
||
let layout_cfg = &state.appearance().scrollback.layout;
|
||
let row_layout = HorizontalLayout::new(
|
||
Rect::new(content_area.x, screen_y, content_area.width, visible_height),
|
||
layout_cfg,
|
||
);
|
||
let sel_area = row_layout.selection_area();
|
||
|
||
let top_clipped = group_start_vy < viewport_start;
|
||
let bottom_clipped = group_end_vy > viewport_end;
|
||
|
||
let sel_box =
|
||
SelectionBox::new(sel_area, Style::default().fg(theme.selection_border))
|
||
.with_top_clipped(top_clipped)
|
||
.with_bottom_clipped(bottom_clipped);
|
||
|
||
content_output.output.selection_box = Some(sel_box);
|
||
content_output.output.selected_entry_area = selected_entry_rect;
|
||
return content_output;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Compute selection box for pinned header (SingleTurn mode)
|
||
if self.is_active
|
||
&& let Some(sel_area) = pinned_header_selection_area
|
||
{
|
||
let top_clipped = area.y == 0;
|
||
// Bottom is not clipped since there's a gap row (pinned_height includes gap)
|
||
let bottom_clipped = false;
|
||
|
||
let sel_box = SelectionBox::new(sel_area, Style::default().fg(theme.selection_border))
|
||
.with_top_clipped(top_clipped)
|
||
.with_bottom_clipped(bottom_clipped);
|
||
|
||
content_output.output.selection_box = Some(sel_box);
|
||
return content_output;
|
||
}
|
||
|
||
content_output
|
||
}
|
||
}
|
||
|
||
/// Screen row the selection caret belongs on within an entry's slot. An
|
||
/// expanded verb-group slot stacks the header line above member 0's own row
|
||
/// and the caret is the MEMBER's affordance, so it sits one row below the
|
||
/// slot top — unless the header is top-clipped off-screen, in which case the
|
||
/// slot's first visible row already IS the member row.
|
||
fn verb_member_indicator_row(slot_top: u16, verb_expanded: bool, header_clipped: bool) -> u16 {
|
||
slot_top + u16::from(verb_expanded && !header_clipped)
|
||
}
|
||
|
||
/// Replace the bullet character on `entry_y` (a screen row) with the
|
||
/// configured expandable indicator (e.g. `›`) when the entry is foldable
|
||
/// and at its minimum fold mode. With `point_down` the indicator is the
|
||
/// down variant (`⌄`) — the hover pass uses it on an expanded verb-group
|
||
/// header row to advertise that the header collapses the group.
|
||
///
|
||
/// Shared between the selected-entry post-pass and the hover post-pass so
|
||
/// the chevron behaves identically in both states — only the bg differs.
|
||
///
|
||
/// No-op when `expandable_indicator` is disabled, the entry isn't
|
||
/// foldable, the entry isn't collapsed (or running + at min fold mode for
|
||
/// streaming blocks like `Execute`/`Thinking`), or the block has no
|
||
/// bullet.
|
||
fn paint_expandable_indicator(
|
||
buf: &mut Buffer,
|
||
content_area: Rect,
|
||
entry_y: u16,
|
||
appearance: &crate::appearance::AppearanceConfig,
|
||
entry: &ScrollbackEntry,
|
||
point_down: bool,
|
||
) {
|
||
let display_cfg = &appearance.scrollback.display;
|
||
if !display_cfg.expandable_indicator {
|
||
return;
|
||
}
|
||
if !entry.block.is_foldable() {
|
||
return;
|
||
}
|
||
let at_min_fold = entry.display_mode == DisplayMode::Collapsed
|
||
|| (display_cfg.expandable_indicator_running
|
||
&& entry.is_running
|
||
&& entry.display_mode == entry.block.collapse_mode(true));
|
||
if !at_min_fold {
|
||
return;
|
||
}
|
||
if !entry.block.has_bullet(&entry.context(0, appearance, None)) {
|
||
return;
|
||
}
|
||
|
||
// The bullet sits at the start of the entry's content area on the
|
||
// first row. `entry_y` is that first row in screen coords. Compute
|
||
// the bullet column from the horizontal layout for a 1-row strip
|
||
// anchored at `entry_y`.
|
||
let layout_cfg = &appearance.scrollback.layout;
|
||
let entry_layout = HorizontalLayout::new(
|
||
Rect::new(content_area.x, entry_y, content_area.width, 1),
|
||
layout_cfg,
|
||
);
|
||
let bullet_x = entry_layout.content.x;
|
||
|
||
if entry_y >= content_area.y
|
||
&& entry_y < content_area.y + content_area.height
|
||
&& let Some(cell) = buf.cell_mut((bullet_x, entry_y))
|
||
{
|
||
// The down variant is fixed (not the configured char): it exists to
|
||
// contrast with the right-pointing state, and `chevron_down` matches
|
||
// `›`'s weight with its own ConHost fallback.
|
||
let ch = if point_down {
|
||
crate::glyphs::chevron_down().chars().next().unwrap_or('v')
|
||
} else {
|
||
display_cfg
|
||
.expandable_indicator_char
|
||
.chars()
|
||
.next()
|
||
.unwrap_or('›')
|
||
};
|
||
cell.set_char(ch);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::verb_member_indicator_row;
|
||
|
||
// Pins the "caret offset ignores clipping" fix: the member caret
|
||
// sits one row below the slot top only while the header row is visible.
|
||
#[test]
|
||
fn verb_member_indicator_row_tracks_header_clipping() {
|
||
// Plain rows: caret on the slot's first row, clipped or not.
|
||
assert_eq!(verb_member_indicator_row(4, false, false), 4);
|
||
assert_eq!(verb_member_indicator_row(4, false, true), 4);
|
||
// Expanded verb slot with the header visible: member row is slot+1.
|
||
assert_eq!(verb_member_indicator_row(4, true, false), 5);
|
||
// Header top-clipped off-screen: the first visible row IS member 0.
|
||
assert_eq!(verb_member_indicator_row(4, true, true), 4);
|
||
}
|
||
}
|