Files
Kigi-CLI/crates/codegen/kigi-tui/src/scrollback/selection.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
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).
2026-07-23 16:55:39 -04:00

412 lines
14 KiB
Rust

//! Selection box rendering for v3 pager.
//!
//! The `SelectionBox` is computed by components (like ScrollbackPane) and rendered
//! by the frame, allowing selection boxes to span component boundaries.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crate::render::osc8::LinkOverlay;
use crate::scrollback::text_selection::ResolvedSelectionModel;
use crate::theme::Theme;
mod border_chars {
pub const TOP_LEFT: char = '┌';
pub const TOP_RIGHT: char = '┐';
pub const BOTTOM_LEFT: char = '└';
pub const BOTTOM_RIGHT: char = '┘';
pub const VERTICAL: char = '│';
/// Dashed vertical - used on edge rows when clipped to indicate continuation.
pub const VERTICAL_DASHED: char = '┆';
}
/// A selection box that can be drawn around a selected block.
///
/// The box consists of:
/// - Side borders (│) on the left and right edges of `inner_area`
/// - Top corners (┌┐) one row above `inner_area` (if `!top_clipped`)
/// - Bottom corners (└┘) one row below `inner_area` (if `!bottom_clipped`)
///
/// This struct is returned by components (like ScrollbackPane) and rendered
/// by the frame, allowing selection boxes to span component boundaries.
#[derive(Debug, Clone)]
pub struct SelectionBox {
/// The inner area surrounded by the selection border.
pub inner_area: Rect,
/// True if the block has rows clipped at top (scrolled out of view).
pub top_clipped: bool,
/// True if the block has rows clipped at bottom.
pub bottom_clipped: bool,
/// Style for the border (typically just fg color).
pub style: Style,
/// Whether to render a close control replacing the top-right corner.
pub closable: bool,
/// Whether the close control is currently hovered.
pub close_hovered: bool,
/// Optional close label; `None` uses default `✗`.
pub close_label: Option<&'static str>,
}
/// Output from render that needs post-processing.
///
/// Render returns this instead of mutating state, keeping render pure.
/// The caller is responsible for rendering these elements after the main pass.
///
/// # Example
/// ```ignore
/// let output = pane.render_with_scratch(area, buf, &state, &mut scratch);
///
/// // Post-render pass
/// if let Some(sel) = output.selection_box {
/// sel.render(buf);
/// }
/// ```
#[derive(Debug, Clone, Default)]
pub struct RenderOutput {
/// Selection box to render around the selected entry.
/// Rendered after main content so it can span component boundaries.
pub selection_box: Option<SelectionBox>,
/// Scroll info for scrollbar rendering.
/// Viewport uses this to render the scrollbar at the correct position.
pub scroll_info: Option<ScrollInfo>,
/// Screen area of the individual selected entry (within a group).
/// Used by agent_view to position inline buttons on the correct row.
pub selected_entry_area: Option<Rect>,
/// Per-frame resolved selection metadata for visible content.
pub selection_model: ResolvedSelectionModel,
/// OSC 8 link overlay for post-flush emission.
pub link_overlay: LinkOverlay,
/// Inline media to render via post-flush escape sequences.
pub inline_media: Vec<crate::scrollback::render::InlineMediaPlacement>,
/// Mermaid diagram affordance rows to paint + register click hit-rects for.
pub diagram_affordances: Vec<crate::scrollback::render::DiagramAffordancePlacement>,
}
/// Scroll information for scrollbar rendering.
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrollInfo {
/// Current scroll offset (lines from top). `usize`: tall sessions exceed
/// `u16::MAX`.
pub scroll_offset: usize,
/// Visible viewport height (lines). Stays `u16` (a terminal is never that tall).
pub viewport_height: u16,
/// Total content height (lines). `usize` for the same reason as `scroll_offset`.
pub total_height: usize,
}
impl RenderOutput {
pub fn new() -> Self {
Self::default()
}
pub fn with_selection_box(selection_box: SelectionBox) -> Self {
Self {
selection_box: Some(selection_box),
scroll_info: None,
selected_entry_area: None,
selection_model: ResolvedSelectionModel::default(),
link_overlay: Default::default(),
inline_media: Vec::new(),
diagram_affordances: Vec::new(),
}
}
pub fn with_scroll_info(mut self, scroll_info: ScrollInfo) -> Self {
self.scroll_info = Some(scroll_info);
self
}
}
impl SelectionBox {
pub fn new(inner_area: Rect, style: Style) -> Self {
Self {
inner_area,
top_clipped: false,
bottom_clipped: false,
style,
closable: false,
close_hovered: false,
close_label: None,
}
}
pub fn with_top_clipped(mut self, clipped: bool) -> Self {
self.top_clipped = clipped;
self
}
pub fn with_bottom_clipped(mut self, clipped: bool) -> Self {
self.bottom_clipped = clipped;
self
}
/// Enable a close control replacing the top-right corner `┐` (default: `✗`).
///
/// Normal state: same color as the border. Hovered: bright white.
pub fn with_closable(mut self, closable: bool, hovered: bool) -> Self {
self.closable = closable;
self.close_hovered = hovered;
self
}
/// Set close control label (`Some` implies closable).
pub fn with_close_label(mut self, label: Option<&'static str>) -> Self {
self.close_label = label;
if label.is_some() {
self.closable = true;
}
self
}
/// Hit-test rect for the close control, if it would be rendered.
///
/// Pure computation — does not touch the buffer. Use for mouse hit-testing.
/// Returns `None` if not closable, top is clipped, or no room.
pub fn close_button_rect(&self) -> Option<Rect> {
if !self.closable || self.top_clipped || self.inner_area.y == 0 {
return None;
}
let label_w = self
.close_label
.map(|s| s.chars().count() as u16)
.unwrap_or(1)
.max(1);
let right_x = self.inner_area.x + self.inner_area.width.saturating_sub(1);
let x = right_x.saturating_sub(label_w.saturating_sub(1));
Some(Rect {
x,
y: self.inner_area.y - 1,
width: label_w,
height: 1,
})
}
/// Render the selection box to the buffer.
///
/// Draws:
/// - Side borders (│) on left and right edges of inner_area
/// - Dashed borders (┆) on edge rows when clipped, to indicate continuation
/// - Top corners (┌┐) at inner_area.y - 1 if !top_clipped and y > 0
/// - Bottom corners (└┘) at inner_area.y + height if !bottom_clipped
/// - Close button (✗) left of ┐ if enabled
pub fn render(&self, buf: &mut Buffer) {
let area = self.inner_area;
if area.width == 0 || area.height == 0 {
return;
}
let left_x = area.x;
let right_x = area.x + area.width.saturating_sub(1);
let y_top = area.y;
let y_bottom = area.y + area.height.saturating_sub(1);
for y in y_top..=y_bottom {
let is_first_row = y == y_top;
let is_last_row = y == y_bottom;
let use_dashed =
(is_first_row && self.top_clipped) || (is_last_row && self.bottom_clipped);
let vert_char = if use_dashed {
border_chars::VERTICAL_DASHED
} else {
border_chars::VERTICAL
};
if let Some(cell) = buf.cell_mut((left_x, y)) {
cell.set_char(vert_char).set_style(self.style);
}
if let Some(cell) = buf.cell_mut((right_x, y)) {
cell.set_char(vert_char).set_style(self.style);
}
}
if !self.top_clipped && y_top > 0 {
let corner_y = y_top - 1;
if let Some(cell) = buf.cell_mut((left_x, corner_y)) {
cell.set_char(border_chars::TOP_LEFT).set_style(self.style);
}
// Close control replaces ┐, or draw normal corner
if let Some(close_rect) = self.close_button_rect() {
let style = if self.close_hovered {
Style::default().fg(Theme::current().text_primary)
} else {
self.style
};
if let Some(label) = self.close_label {
use crate::render::SafeBuf;
buf.set_string_safe(close_rect.x, close_rect.y, label, style);
} else if let Some(cell) = buf.cell_mut((close_rect.x, close_rect.y)) {
cell.set_symbol(crate::glyphs::ballot_x()).set_style(style);
}
} else if let Some(cell) = buf.cell_mut((right_x, corner_y)) {
cell.set_char(border_chars::TOP_RIGHT).set_style(self.style);
}
}
if !self.bottom_clipped {
let corner_y = y_bottom + 1;
if let Some(cell) = buf.cell_mut((left_x, corner_y)) {
cell.set_char(border_chars::BOTTOM_LEFT)
.set_style(self.style);
}
if let Some(cell) = buf.cell_mut((right_x, corner_y)) {
cell.set_char(border_chars::BOTTOM_RIGHT)
.set_style(self.style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_selection_box_render() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let selection = SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default());
selection.render(&mut buf);
// Check top corners at y=1 (inner_area.y - 1)
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_eq!(buf.cell((9, 1)).unwrap().symbol(), "┐");
for y in 2..=5 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
// Check bottom corners at y=6 (inner_area.y + height)
assert_eq!(buf.cell((0, 6)).unwrap().symbol(), "└");
assert_eq!(buf.cell((9, 6)).unwrap().symbol(), "┘");
}
#[test]
fn test_selection_box_top_clipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let selection =
SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default()).with_top_clipped(true);
selection.render(&mut buf);
assert_ne!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_ne!(buf.cell((9, 1)).unwrap().symbol(), "┐");
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 2)).unwrap().symbol(), "┆");
for y in 3..=5 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
assert_eq!(buf.cell((0, 6)).unwrap().symbol(), "└");
}
#[test]
fn test_selection_box_bottom_clipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let selection =
SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default()).with_bottom_clipped(true);
selection.render(&mut buf);
assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_eq!(buf.cell((9, 1)).unwrap().symbol(), "┐");
for y in 2..=4 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
assert_eq!(buf.cell((0, 5)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 5)).unwrap().symbol(), "┆");
assert_ne!(buf.cell((0, 6)).unwrap().symbol(), "└");
assert_ne!(buf.cell((9, 6)).unwrap().symbol(), "┘");
}
#[test]
fn test_selection_box_both_clipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let selection = SelectionBox::new(Rect::new(0, 2, 10, 4), Style::default())
.with_top_clipped(true)
.with_bottom_clipped(true);
selection.render(&mut buf);
assert_ne!(buf.cell((0, 1)).unwrap().symbol(), "┌");
assert_ne!(buf.cell((0, 6)).unwrap().symbol(), "└");
assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 2)).unwrap().symbol(), "┆");
for y in 3..=4 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
assert_eq!(buf.cell((9, y)).unwrap().symbol(), "│");
}
assert_eq!(buf.cell((0, 5)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 5)).unwrap().symbol(), "┆");
}
#[test]
fn test_selection_box_single_row_both_clipped() {
// Edge case: only 1 row visible, both ends clipped
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let selection = SelectionBox::new(Rect::new(0, 3, 10, 1), Style::default())
.with_top_clipped(true)
.with_bottom_clipped(true);
selection.render(&mut buf);
// The single row should have DASHED borders (first row = last row, both clipped)
assert_eq!(buf.cell((0, 3)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 3)).unwrap().symbol(), "┆");
assert_ne!(buf.cell((0, 2)).unwrap().symbol(), "┌");
assert_ne!(buf.cell((0, 4)).unwrap().symbol(), "└");
}
#[test]
fn test_selection_box_single_row_top_clipped_only() {
// Edge case: only 1 row visible, only top clipped
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
let selection =
SelectionBox::new(Rect::new(0, 3, 10, 1), Style::default()).with_top_clipped(true);
selection.render(&mut buf);
// The single row should have DASHED borders (it's first row and top_clipped)
assert_eq!(buf.cell((0, 3)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((9, 3)).unwrap().symbol(), "┆");
assert_eq!(buf.cell((0, 4)).unwrap().symbol(), "└");
assert_eq!(buf.cell((9, 4)).unwrap().symbol(), "┘");
}
#[test]
fn test_selection_box_at_top_edge() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
// Selection at y=0 (no room for top corners even if not clipped)
let selection = SelectionBox::new(Rect::new(0, 0, 10, 4), Style::default());
selection.render(&mut buf);
for y in 0..=3 {
assert_eq!(buf.cell((0, y)).unwrap().symbol(), "│");
}
assert_eq!(buf.cell((0, 4)).unwrap().symbol(), "└");
}
}