M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,652 @@
//! Color blending and fading utilities.
//!
//! These utilities support smooth fade transitions (e.g., for sticky headers
//! being pushed off screen) by blending colors toward a base color.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::text::{Line, Span};
/// The 6 channel values in the 256-color 6×6×6 cube.
const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// Convert a 256-color indexed color to its (R, G, B) components.
///
/// Handles all three regions of the 256-color palette:
/// - 015: standard/bright ANSI colors (uses common xterm defaults)
/// - 16231: 6×6×6 color cube
/// - 232255: 24-step grayscale ramp
pub fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
match index {
// Standard colors (07) — common xterm defaults
0 => (0, 0, 0),
1 => (128, 0, 0),
2 => (0, 128, 0),
3 => (128, 128, 0),
4 => (0, 0, 128),
5 => (128, 0, 128),
6 => (0, 128, 128),
7 => (192, 192, 192),
// Bright colors (815)
8 => (128, 128, 128),
9 => (255, 0, 0),
10 => (0, 255, 0),
11 => (255, 255, 0),
12 => (0, 0, 255),
13 => (255, 0, 255),
14 => (0, 255, 255),
15 => (255, 255, 255),
// 6×6×6 color cube (16231)
16..=231 => {
let n = index - 16;
let r = CUBE_VALUES[(n / 36) as usize];
let g = CUBE_VALUES[((n % 36) / 6) as usize];
let b = CUBE_VALUES[(n % 6) as usize];
(r, g, b)
}
// Grayscale ramp (232255): value = 8 + (index 232) × 10
232..=255 => {
let v = 8 + (index - 232) * 10;
(v, v, v)
}
}
}
/// Map an RGB triplet to the nearest 256-color palette index (16255).
///
/// Searches both the 6×6×6 color cube (16231) and the 24-step grayscale
/// ramp (232255), returning whichever has the smallest squared Euclidean
/// distance.
pub fn nearest_indexed(r: u8, g: u8, b: u8) -> u8 {
// --- nearest in the 6×6×6 color cube (16231) ---
let ri = nearest_cube_channel(r);
let gi = nearest_cube_channel(g);
let bi = nearest_cube_channel(b);
let cube_idx = 16 + 36 * ri as u16 + 6 * gi as u16 + bi as u16;
let cube_dist = sq_dist(
r,
g,
b,
CUBE_VALUES[ri as usize],
CUBE_VALUES[gi as usize],
CUBE_VALUES[bi as usize],
);
// --- nearest in the grayscale ramp (232255) ---
// Ramp values: 8, 18, 28, …, 238 (24 entries)
let lum = (r as u16 + g as u16 + b as u16) / 3;
let gray_step = if lum <= 3 {
0u8
} else if lum >= 243 {
23
} else {
((lum as i16 - 8 + 5) / 10).clamp(0, 23) as u8
};
let gv = (8 + gray_step as u16 * 10) as u8;
let gray_dist = sq_dist(r, g, b, gv, gv, gv);
if gray_dist < cube_dist {
232 + gray_step
} else {
cube_idx as u8
}
}
/// Find the nearest index (05) into [`CUBE_VALUES`] for a single channel.
fn nearest_cube_channel(v: u8) -> u8 {
let mut best = 0u8;
let mut best_d = v.abs_diff(CUBE_VALUES[0]) as u16;
for i in 1..6u8 {
let d = v.abs_diff(CUBE_VALUES[i as usize]) as u16;
if d < best_d {
best = i;
best_d = d;
}
}
best
}
/// Squared Euclidean distance between two RGB colors.
fn sq_dist(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
let dr = r1 as i32 - r2 as i32;
let dg = g1 as i32 - g2 as i32;
let db = b1 as i32 - b2 as i32;
(dr * dr + dg * dg + db * db) as u32
}
/// Extract (R, G, B) from a Color, supporting both Rgb and Indexed variants.
///
/// Returns `None` for named ANSI colors (Color::Red, etc.) and Color::Reset.
fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
match color {
Color::Rgb(r, g, b) => Some((r, g, b)),
Color::Indexed(n) => Some(indexed_to_rgb(n)),
_ => None,
}
}
/// Map every [`Color`] variant to an xterm-default RGB triple. `None`
/// only for `Color::Reset` (no defined RGB — caller chooses a fallback).
///
/// Useful when downstream code must produce RGB for *every* color value
/// — e.g. progress-bar gradients that lerp across named breakpoints, or
/// OSC 12 cursor-color updates that must emit an RGB triple regardless
/// of terminal color depth.
///
/// Named-color RGB matches the xterm 16-color palette used by
/// [`indexed_to_rgb`] for indices 015; the user's terminal may have
/// customised those entries, so the result is "approximate but
/// consistent with our other colorimetry".
pub fn resolve_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
let idx: u8 = match color {
Color::Rgb(r, g, b) => return Some((r, g, b)),
Color::Indexed(n) => return Some(indexed_to_rgb(n)),
Color::Black => 0,
Color::Red => 1,
Color::Green => 2,
Color::Yellow => 3,
Color::Blue => 4,
Color::Magenta => 5,
Color::Cyan => 6,
Color::Gray => 7,
Color::DarkGray => 8,
Color::LightRed => 9,
Color::LightGreen => 10,
Color::LightYellow => 11,
Color::LightBlue => 12,
Color::LightMagenta => 13,
Color::LightCyan => 14,
Color::White => 15,
Color::Reset => return None,
};
Some(indexed_to_rgb(idx))
}
/// Blend a single color channel: lerp from base toward original based on opacity.
///
/// - `opacity = 0.0`: returns `base` (fully faded)
/// - `opacity = 1.0`: returns `original` (no change)
#[inline]
pub fn blend_channel(base: u8, original: u8, opacity: f32) -> u8 {
// result = base + (original - base) * opacity
// = base * (1 - opacity) + original * opacity
let result = base as f32 * (1.0 - opacity) + original as f32 * opacity;
result.round() as u8
}
/// Blend a color toward a base color based on opacity.
///
/// - `opacity = 0.0`: returns `base` (fully faded)
/// - `opacity = 1.0`: returns `original` (no change)
///
/// Supports both `Color::Rgb` and `Color::Indexed` colors (indexed colors are
/// converted to their RGB equivalents for blending). When either input is
/// `Color::Indexed`, the blended result is quantized back to the nearest
/// 256-color index so the output stays terminal-compatible.
///
/// Returns `None` for named ANSI colors (Color::Red, etc.) since their RGB
/// values are terminal-dependent.
pub fn blend_color(base: Color, original: Color, opacity: f32) -> Option<Color> {
let (base_r, base_g, base_b) = color_to_rgb(base)?;
let (orig_r, orig_g, orig_b) = color_to_rgb(original)?;
let r = blend_channel(base_r, orig_r, opacity);
let g = blend_channel(base_g, orig_g, opacity);
let b = blend_channel(base_b, orig_b, opacity);
// When either input is indexed, quantize the blended result back to the
// nearest 256-color index so the output stays terminal-compatible.
// On 256-color terminals the theme quantizes all colors to Indexed at
// startup, so any Indexed input signals that the terminal cannot handle
// raw RGB — the output must stay in the indexed palette.
Some(match (base, original) {
(Color::Indexed(_), _) | (_, Color::Indexed(_)) => Color::Indexed(nearest_indexed(r, g, b)),
_ => Color::Rgb(r, g, b),
})
}
/// Blend all span colors in a line toward a base color.
///
/// This is useful for making content appear "faded" or "muted" by blending
/// its colors toward the background.
///
/// - `opacity = 0.0`: fully faded to base color
/// - `opacity = 1.0`: no change (original colors)
///
/// Named ANSI colors are left unchanged.
pub fn blend_line(line: Line<'static>, base: Color, opacity: f32) -> Line<'static> {
let blended_spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| {
let mut style = span.style;
if let Some(fg) = style.fg
&& let Some(blended) = blend_color(base, fg, opacity)
{
style.fg = Some(blended);
}
Span::styled(span.content, style)
})
.collect();
Line::from(blended_spans).style(line.style)
}
/// Blend all span colors in a line toward a base color, with default foreground.
///
/// Like `blend_line`, but spans without an explicit fg color are assigned
/// `default_fg` before blending. This ensures all text gets blended, not just
/// explicitly colored text.
///
/// - `opacity = 0.0`: fully faded to base color
/// - `opacity = 1.0`: no change (original colors)
///
/// Named ANSI colors are left unchanged.
pub fn blend_line_with_default(
line: Line<'static>,
base: Color,
default_fg: Color,
opacity: f32,
) -> Line<'static> {
let blended_spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| {
let mut style = span.style;
// Use default_fg if no explicit fg color
let fg = style.fg.unwrap_or(default_fg);
if let Some(blended) = blend_color(base, fg, opacity) {
style.fg = Some(blended);
}
Span::styled(span.content, style)
})
.collect();
Line::from(blended_spans).style(line.style)
}
/// Fade a region of the buffer toward a base color.
///
/// This blends both foreground and background colors of each cell toward
/// `base_color` based on `opacity`:
/// - `opacity = 0.0`: fully faded (cells become base_color)
/// - `opacity = 1.0`: no change
///
/// Both RGB and Indexed colors are blended; named ANSI colors (Color::Red, etc.)
/// are left unchanged since their RGB values are terminal-dependent.
pub fn fade_region(buf: &mut Buffer, area: Rect, base_color: Color, opacity: f32) {
blend_area(
buf,
area,
Some((base_color, opacity)),
Some((base_color, opacity)),
);
}
/// Blend fg and/or bg of every cell in an area toward target colors.
///
/// Each parameter is `Option<(target, opacity)>`:
/// - `None`: leave that channel unchanged
/// - `Some((target, opacity))`: blend toward `target` at `opacity`
/// - `opacity = 0.0`: fully target (original gone)
/// - `opacity = 1.0`: no change (original kept)
///
/// Both RGB and Indexed colors are blended; named ANSI color cells are skipped.
pub fn blend_area(
buf: &mut Buffer,
area: Rect,
fg: Option<(Color, f32)>,
bg: Option<(Color, f32)>,
) {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
if let Some((target, opacity)) = fg
&& let Some(blended) = blend_color(target, cell.fg, opacity)
{
cell.set_fg(blended);
}
if let Some((target, opacity)) = bg
&& let Some(blended) = blend_color(target, cell.bg, opacity)
{
cell.set_bg(blended);
}
}
}
}
}
/// Dim a screen area: reset all modifiers then blend toward a background color.
///
/// This ensures no bold/italic/underline bleeds through the dimmed overlay.
pub fn dim_area(buf: &mut Buffer, area: Rect, blend_bg: ratatui::style::Color, blend_factor: f32) {
use ratatui::style::Modifier;
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
// Strip all modifiers (BOLD, ITALIC, UNDERLINE, etc.).
cell.modifier = Modifier::empty();
}
}
}
// Then blend colors.
crate::render::color::blend_area(buf, area, Some((blend_bg, blend_factor)), None);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nearest_indexed_exact_cube_values() {
// Pure black in the cube → index 16
assert_eq!(nearest_indexed(0, 0, 0), 16);
// Pure white in the cube → index 231
assert_eq!(nearest_indexed(255, 255, 255), 231);
// Exact cube hit: rgb(95, 135, 215) → 16 + 36*1 + 6*2 + 4 = 68
assert_eq!(nearest_indexed(95, 135, 215), 68);
}
#[test]
fn test_nearest_indexed_grayscale() {
// Mid-gray should map to a grayscale index
let idx = nearest_indexed(128, 128, 128);
assert!((232..=255).contains(&idx));
}
#[test]
fn test_nearest_indexed_roundtrip() {
// A known indexed color should round-trip back to itself
for &idx in &[16u8, 141, 149, 210, 234, 243, 245, 255] {
let (r, g, b) = indexed_to_rgb(idx);
assert_eq!(
nearest_indexed(r, g, b),
idx,
"round-trip failed for index {idx}"
);
}
}
#[test]
fn test_blend_channel_extremes() {
// opacity = 0: fully base
assert_eq!(blend_channel(0, 255, 0.0), 0);
assert_eq!(blend_channel(100, 200, 0.0), 100);
// opacity = 1: fully original
assert_eq!(blend_channel(0, 255, 1.0), 255);
assert_eq!(blend_channel(100, 200, 1.0), 200);
}
#[test]
fn test_blend_channel_midpoint() {
// opacity = 0.5: halfway between
assert_eq!(blend_channel(0, 100, 0.5), 50);
assert_eq!(blend_channel(100, 200, 0.5), 150);
assert_eq!(blend_channel(0, 255, 0.5), 128); // 127.5 rounds to 128
}
#[test]
fn test_blend_channel_partial() {
// 25% opacity
assert_eq!(blend_channel(0, 100, 0.25), 25);
// 75% opacity
assert_eq!(blend_channel(0, 100, 0.75), 75);
}
#[test]
fn test_blend_color_rgb() {
let base = Color::Rgb(0, 0, 0);
let original = Color::Rgb(100, 150, 200);
// Fully faded
let faded = blend_color(base, original, 0.0);
assert_eq!(faded, Some(Color::Rgb(0, 0, 0)));
// No change
let unchanged = blend_color(base, original, 1.0);
assert_eq!(unchanged, Some(Color::Rgb(100, 150, 200)));
// Halfway
let half = blend_color(base, original, 0.5);
assert_eq!(half, Some(Color::Rgb(50, 75, 100)));
}
#[test]
fn test_blend_color_indexed_returns_indexed() {
// Both indexed → result is indexed (quantized back to 256-color palette)
let base = Color::Indexed(232); // near-black (8, 8, 8)
let original = Color::Indexed(255); // near-white (238, 238, 238)
let half = blend_color(base, original, 0.5).unwrap();
assert!(matches!(half, Color::Indexed(_)));
// Fully base
let faded = blend_color(base, original, 0.0).unwrap();
assert!(matches!(faded, Color::Indexed(_)));
// Fully original
let full = blend_color(base, original, 1.0).unwrap();
assert!(matches!(full, Color::Indexed(_)));
}
#[test]
fn test_blend_color_mixed_returns_indexed() {
let rgb = Color::Rgb(100, 100, 100);
let indexed = Color::Indexed(5); // magenta (128, 0, 128)
// Mixed: indexed base + rgb original → Indexed result (quantized)
let result = blend_color(indexed, rgb, 0.5);
assert!(
matches!(result, Some(Color::Indexed(_))),
"expected Indexed, got {result:?}"
);
// Mixed: rgb base + indexed original → Indexed result (quantized)
let result = blend_color(rgb, indexed, 0.5);
assert!(
matches!(result, Some(Color::Indexed(_))),
"expected Indexed, got {result:?}"
);
}
#[test]
fn test_blend_color_named_returns_none() {
let rgb = Color::Rgb(100, 100, 100);
let named = Color::Red;
// Named ANSI colors are not blendable
assert_eq!(blend_color(named, rgb, 0.5), None);
assert_eq!(blend_color(rgb, named, 0.5), None);
}
#[test]
fn test_fade_region() {
let mut buf = Buffer::empty(Rect::new(0, 0, 3, 2));
// Set up some RGB colors
let fg_color = Color::Rgb(200, 200, 200);
let bg_color = Color::Rgb(50, 50, 50);
let base = Color::Rgb(0, 0, 0);
for y in 0..2 {
for x in 0..3 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(fg_color);
cell.set_bg(bg_color);
}
}
}
// Fade to 50%
fade_region(&mut buf, Rect::new(0, 0, 3, 2), base, 0.5);
// Check cells are faded
if let Some(cell) = buf.cell((0, 0)) {
assert_eq!(cell.fg, Color::Rgb(100, 100, 100)); // 200 * 0.5
assert_eq!(cell.bg, Color::Rgb(25, 25, 25)); // 50 * 0.5
}
}
#[test]
fn test_fade_region_partial_area() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
let fg_color = Color::Rgb(100, 100, 100);
let base = Color::Rgb(0, 0, 0);
// Set all cells
for y in 0..4 {
for x in 0..4 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(fg_color);
}
}
}
// Only fade a 2x2 region in the middle
fade_region(&mut buf, Rect::new(1, 1, 2, 2), base, 0.0);
// Corner should be unchanged
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 100, 100));
// Middle should be fully faded
assert_eq!(buf.cell((1, 1)).unwrap().fg, Color::Rgb(0, 0, 0));
assert_eq!(buf.cell((2, 2)).unwrap().fg, Color::Rgb(0, 0, 0));
// Other corner unchanged
assert_eq!(buf.cell((3, 3)).unwrap().fg, Color::Rgb(100, 100, 100));
}
#[test]
fn test_blend_area_fg_only() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
let fg = Color::Rgb(200, 100, 0);
let bg = Color::Rgb(10, 10, 10);
for x in 0..2 {
if let Some(cell) = buf.cell_mut((x, 0)) {
cell.set_fg(fg);
cell.set_bg(bg);
}
}
let target = Color::Rgb(0, 0, 0);
blend_area(&mut buf, Rect::new(0, 0, 2, 1), Some((target, 0.5)), None);
// fg blended to 50%
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 50, 0));
// bg unchanged
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(10, 10, 10));
}
#[test]
fn test_blend_area_bg_only() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
let fg = Color::Rgb(200, 200, 200);
let bg = Color::Rgb(100, 100, 100);
for x in 0..2 {
if let Some(cell) = buf.cell_mut((x, 0)) {
cell.set_fg(fg);
cell.set_bg(bg);
}
}
let target = Color::Rgb(0, 0, 0);
blend_area(&mut buf, Rect::new(0, 0, 2, 1), None, Some((target, 0.5)));
// fg unchanged
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(200, 200, 200));
// bg blended to 50%
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(50, 50, 50));
}
#[test]
fn test_blend_area_both() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
if let Some(cell) = buf.cell_mut((0, 0)) {
cell.set_fg(Color::Rgb(100, 200, 0));
cell.set_bg(Color::Rgb(50, 50, 50));
}
let fg_target = Color::Rgb(0, 0, 0);
let bg_target = Color::Rgb(20, 20, 20);
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
Some((fg_target, 0.75)),
Some((bg_target, 0.75)),
);
// fg: 75% of (100,200,0) + 25% of (0,0,0) = (75,150,0)
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(75, 150, 0));
// bg: 75% of (50,50,50) + 25% of (20,20,20) = (42.5, 42.5, 42.5) → (43,43,43)
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(43, 43, 43));
}
#[test]
fn test_blend_area_none_none_is_noop() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
let fg = Color::Rgb(123, 45, 67);
let bg = Color::Rgb(89, 10, 11);
for y in 0..2 {
for x in 0..2 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(fg);
cell.set_bg(bg);
}
}
}
blend_area(&mut buf, Rect::new(0, 0, 2, 2), None, None);
for y in 0..2 {
for x in 0..2 {
assert_eq!(buf.cell((x, y)).unwrap().fg, fg);
assert_eq!(buf.cell((x, y)).unwrap().bg, bg);
}
}
}
#[test]
fn test_blend_area_named_color_skipped() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
if let Some(cell) = buf.cell_mut((0, 0)) {
cell.set_fg(Color::Red); // named color — blend_color returns None
cell.set_bg(Color::Red);
}
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
Some((Color::Rgb(0, 0, 0), 0.5)),
Some((Color::Rgb(0, 0, 0), 0.5)),
);
// Named colors should be unchanged (blend_color returns None for them)
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Red);
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Red);
}
#[test]
fn test_blend_area_indexed_colors_blended() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
if let Some(cell) = buf.cell_mut((0, 0)) {
cell.set_fg(Color::Indexed(255)); // near-white grayscale
cell.set_bg(Color::Indexed(255));
}
// Blend toward black (indexed 232 = #080808, but we use indexed 16 = #000000)
let target = Color::Indexed(16); // black in the color cube
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
Some((target, 0.5)),
Some((target, 0.5)),
);
// Both should now be blended (and still indexed, not Rgb)
let cell = buf.cell((0, 0)).unwrap();
assert!(matches!(cell.fg, Color::Indexed(_)));
assert!(matches!(cell.bg, Color::Indexed(_)));
}
}
@@ -0,0 +1,573 @@
//! Frame drawing with cursor blink preservation.
//!
//! # Problem
//!
//! Ratatui's [`Terminal::draw()`] (internally `try_draw()`) unconditionally
//! sends cursor escape sequences on every frame:
//!
//! - If `frame.set_cursor_position()` was called: `Show` + `MoveTo` every frame
//! - If not called: `Hide` every frame
//!
//! Both reset the terminal's cursor blink timer (`Show` restarts the blink
//! cycle, `MoveTo` resets the blink phase). At 30fps, the 500ms blink interval
//! never completes, so the cursor appears solid.
//!
//! # Solution
//!
//! We bypass `try_draw()` and use ratatui's lower-level API directly:
//!
//! ```text
//! terminal.autoresize() — handle terminal size changes
//! terminal.get_frame() — get a fresh buffer to render into
//! terminal.flush() — diff old/new buffers, write only changed cells
//! terminal.swap_buffers() — prepare for next frame
//! ```
//!
//! Cursor is managed entirely by [`CursorState`] with de-duplication:
//!
//! - **No cell changes + same position**: zero cursor commands → blink preserved
//! - **Cells changed + same position**: `MoveTo` to fix cursor after cell writes
//! - **Position changed**: `MoveTo` (blink resets — expected, user just typed)
//! - **Visibility transition**: `Show`/`Hide` (only on actual transition)
//! - **Idle (no draw calls)**: nothing sent → blink runs undisturbed
//!
//! The "no cell changes" optimization is possible because we use
//! [`kigi_ratatui_inline::Terminal`] whose `flush()` returns `bool` indicating
//! whether any cells were written. When animated entries are off-screen, the
//! buffer diff is empty and we skip all cursor commands.
//!
//! # Synchronized output
//!
//! Each frame is wrapped in `BeginSynchronizedUpdate` / `EndSynchronizedUpdate`
//! so the terminal processes all escape sequences atomically. This prevents
//! flicker and is critical for multiplexers like zellij and tmux.
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use crossterm::{QueueableCommand, cursor};
use kigi_ratatui_inline::LinkSpan;
use ratatui::Frame;
use ratatui::backend::CrosstermBackend;
use std::io::Write;
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// Terminal type for the pager. Defined here (beside [`TermWriter`]) so the
/// `render` module does not depend on `app`. Re-exported from `app` as
/// `crate::app::PagerTerminal` for existing call sites.
pub type PagerTerminal = kigi_ratatui_inline::Terminal<CrosstermBackend<TermWriter>>;
/// Shared queued/written frame counters linking [`TermWriter`] to the writer
/// thread, so callers can wait for the output pipeline to drain.
///
/// The channel between them is fire-and-forget by design (the event loop must
/// never block on pty I/O), but a few operations need a *happens-before* on
/// terminal bytes: suspending into a tty-taking child (`$EDITOR` / `$PAGER`)
/// while a frame is still queued lets that frame race the child's own output —
/// it can land on the child's alternate screen (so the main screen never
/// receives it) or tear mid-escape-sequence around the alt-screen switch,
/// leaving the restored screen out of sync with the renderer's diff buffer
/// (stale rows, one-line offsets, literal `[` fragments). [`wait_drained`]
/// closes that window.
///
/// `queued` is incremented *before* the frame is sent and `written` after the
/// writer thread has flushed it to the tty, so `written == queued` ⇒ every
/// frame handed to the channel has reached the terminal fd.
///
/// [`wait_drained`]: WriterSync::wait_drained
#[derive(Clone, Debug, Default)]
pub struct WriterSync {
queued: std::sync::Arc<std::sync::atomic::AtomicU64>,
written: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl WriterSync {
pub fn new() -> Self {
Self::default()
}
/// Record a frame handed to the channel. Called by [`TermWriter::flush`]
/// *before* the send so `written` can never observably exceed `queued`.
fn mark_queued(&self) {
self.queued
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
/// Record a frame fully written + flushed to the tty (writer thread).
fn mark_written(&self) {
self.written
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
/// Whether every queued frame has been written to the tty.
pub fn is_drained(&self) -> bool {
self.written.load(std::sync::atomic::Ordering::SeqCst)
>= self.queued.load(std::sync::atomic::Ordering::SeqCst)
}
/// Block (bounded) until the writer thread has flushed every queued frame.
///
/// Returns `true` when drained, `false` on timeout (wedged pty / dead
/// writer thread — callers proceed anyway, matching the bounded
/// reader-park in the suspend path).
pub fn wait_drained(&self, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while !self.is_drained() {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(1));
}
true
}
}
/// A writer that buffers frame output and sends it to a background thread
/// for non-blocking terminal I/O.
///
/// All escape sequences produced during a frame are collected in an internal
/// `Vec<u8>`. When [`flush()`](Write::flush) is called, the accumulated bytes
/// are sent through a channel to a dedicated writer thread that performs the
/// actual (potentially blocking) `write()` to stderr / the pty fd.
///
/// This decouples the tokio event loop from pty back-pressure: if the
/// terminal emulator is slow to read (e.g. Ghostty busy with another pane),
/// only the writer thread stalls — the event loop keeps processing timers,
/// events, and ACP messages.
pub struct TermWriter {
buf: Vec<u8>,
tx: mpsc::Sender<Vec<u8>>,
sync: WriterSync,
}
impl TermWriter {
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
Self {
buf: Vec::with_capacity(32 * 1024),
tx,
sync,
}
}
/// Drop the current frame's buffered bytes without sending them.
pub fn discard(&mut self) {
self.buf.clear();
}
/// The queued/written counters shared with the writer thread. Used by the
/// suspend path to [`WriterSync::wait_drained`] before a child takes the tty.
pub fn writer_sync(&self) -> &WriterSync {
&self.sync
}
}
impl Write for TermWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if !self.buf.is_empty() {
let data = std::mem::take(&mut self.buf);
self.sync.mark_queued();
let _ = self.tx.send(data);
}
Ok(())
}
}
impl Drop for TermWriter {
fn drop(&mut self) {
let _ = self.flush();
}
}
/// Handle for the background writer thread.
///
/// Joining ensures all queued frames have been written to the terminal
/// before proceeding with teardown (e.g. `LeaveAlternateScreen`).
pub struct WriterThread {
handle: Option<std::thread::JoinHandle<()>>,
}
impl WriterThread {
/// Block until the writer thread has processed all pending frames and
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
/// otherwise the thread will never see the channel close.
pub fn join(mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for WriterThread {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
/// Spawn a background OS thread that writes frame data to stderr.
///
/// Returns `(Sender, WriterSync, WriterThread)`. Send `Vec<u8>` frame data
/// through the sender; the thread writes each frame to stderr via a 64 KiB
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
/// Drop the sender to signal the thread to exit, then call
/// [`WriterThread::join`] to wait for it.
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let thread_sync = sync.clone();
let test_delay = std::env::var("KIGI_TEST_FRAME_WRITE_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_millis);
let handle = std::thread::Builder::new()
.name("term-writer".into())
.spawn(move || {
#[cfg(not(windows))]
let mut writer: Box<dyn std::io::Write> = {
let tui_out = kigi_tty_utils::dup_tui_stderr().unwrap_or_else(|_| {
use std::os::unix::io::{AsRawFd, FromRawFd};
let fd = unsafe { libc::dup(std::io::stderr().as_raw_fd()) };
unsafe { std::fs::File::from_raw_fd(fd) }
});
Box::new(std::io::BufWriter::with_capacity(64 * 1024, tui_out))
};
#[cfg(windows)]
let mut writer: Box<dyn std::io::Write> = Box::new(std::io::BufWriter::with_capacity(
64 * 1024,
std::io::stderr(),
));
while let Ok(data) = rx.recv() {
if let Some(delay) = test_delay {
std::thread::sleep(delay);
}
{
let _guard = kigi_shared::stderr::stderr_lock();
let _ = writer.write_all(&data);
let _ = writer.flush();
}
thread_sync.mark_written();
}
})
.expect("failed to spawn term-writer thread");
(
tx,
sync,
WriterThread {
handle: Some(handle),
},
)
}
/// Cursor state tracker for blink-preserving cursor management.
///
/// Tracks the last cursor position written to the terminal. By comparing
/// with the desired position each frame, we emit the minimum cursor escape
/// sequences necessary — avoiding redundant `Show`/`Hide`/`MoveTo` that
/// would reset the terminal's blink timer.
#[derive(Debug, Default)]
pub struct CursorState {
/// Last cursor position written to the terminal.
/// `None` = cursor is hidden; `Some((x, y))` = cursor visible at (x, y).
last_pos: Option<(u16, u16)>,
}
/// What cursor commands to emit after a frame render.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorAction {
/// No cursor commands needed — blink timer preserved.
None,
/// Cursor is visible and cells changed — reposition after cell writes
/// disturbed the terminal cursor. Resets blink (unavoidable when cells
/// change on screen).
Reposition(u16, u16),
/// Cursor becoming visible at (x, y) — needs `MoveTo` + `Show`.
Show(u16, u16),
/// Cursor becoming hidden — needs `Hide`.
Hide,
}
impl CursorState {
pub fn new() -> Self {
Self { last_pos: None }
}
/// Determine what cursor action to take for this frame.
///
/// Pure function — computes the action from current state without
/// side effects. Call [`apply`] to execute it.
pub fn action(&self, cursor_pos: Option<(u16, u16)>, has_changes: bool) -> CursorAction {
if cursor_pos == self.last_pos {
if has_changes && let Some((x, y)) = cursor_pos {
return CursorAction::Reposition(x, y);
}
CursorAction::None
} else {
match (cursor_pos, self.last_pos) {
(Some((x, y)), Some(_)) => CursorAction::Reposition(x, y),
(Some((x, y)), None) => CursorAction::Show(x, y),
(None, Some(_)) => CursorAction::Hide,
(None, None) => CursorAction::None,
}
}
}
/// Execute a cursor action by queuing escape sequences into `w`.
///
/// Uses `queue!` (buffered) instead of `execute!` (immediate flush) so
/// that cursor commands are batched with the rest of the frame data and
/// written to the terminal atomically by the writer thread.
pub fn apply<W: Write>(&mut self, action: CursorAction, w: &mut W) {
match action {
CursorAction::None => {}
CursorAction::Reposition(x, y) => {
let _ = w.queue(cursor::MoveTo(x, y));
self.last_pos = Some((x, y));
}
CursorAction::Show(x, y) => {
let _ = w.queue(cursor::MoveTo(x, y));
let _ = w.queue(cursor::Show);
self.last_pos = Some((x, y));
}
CursorAction::Hide => {
let _ = w.queue(cursor::Hide);
self.last_pos = None;
}
}
}
}
/// Render a frame to the terminal with cursor blink preservation.
///
/// Bypasses ratatui's `try_draw()` to avoid its unconditional cursor
/// management. See [module docs](self) for the full rationale.
///
/// The `render_fn` receives a [`Frame`] and a `&mut Vec<LinkSpan>` to populate
/// with the frame's OSC 8 hyperlink regions (absolute viewport coordinates).
/// Those spans are handed to the terminal before the diff so hyperlinks
/// participate in the cell diff (emitted/cleared in lockstep with content) —
/// no out-of-band post-flush repaint. It returns a tuple of:
/// - `Option<(u16, u16)>` — cursor position (or `None` to hide cursor)
/// - `Option<PostFlush>` — escape sequences to write after cell flush (e.g.
/// Kitty graphics protocol image data). Written inside the synchronized
/// update block so the image appears atomically with the cell diff.
pub fn draw_frame(
terminal: &mut PagerTerminal,
cursor: &mut CursorState,
render_fn: impl FnOnce(
&mut Frame,
&mut Vec<LinkSpan>,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
),
) {
let _ = terminal.backend_mut().queue(BeginSynchronizedUpdate);
let _ = terminal.autoresize();
let mut link_spans: Vec<LinkSpan> = Vec::new();
let (cursor_pos, post_flush_escapes) = {
let mut frame = terminal.get_frame();
render_fn(&mut frame, &mut link_spans)
};
terminal.set_frame_links(&link_spans);
let has_changes = terminal.flush_with_links().unwrap_or(false);
terminal.swap_buffers();
let post_flush_wrote_cursor = post_flush_escapes.is_some();
let action = cursor.action(cursor_pos, has_changes || post_flush_wrote_cursor);
if !has_changes && !post_flush_wrote_cursor && action == CursorAction::None {
terminal.backend_mut().writer_mut().discard();
return;
}
if let Some(post_flush) = post_flush_escapes {
let _ = post_flush.write_to(terminal.backend_mut());
}
cursor.apply(action, terminal.backend_mut());
let _ = terminal.backend_mut().queue(EndSynchronizedUpdate);
let _ = terminal.backend_mut().flush();
}
#[cfg(test)]
mod tests {
use super::*;
/// An unchanged frame must emit zero bytes to the PTY.
#[test]
fn idle_frame_emits_zero_bytes() {
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport};
use std::sync::mpsc;
fn render(
frame: &mut ratatui::Frame,
_links: &mut Vec<LinkSpan>,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
) {
frame.render_widget(Paragraph::new("hello world"), frame.area());
(None, None)
}
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let backend = CrosstermBackend::new(TermWriter::new(tx, WriterSync::new()));
let mut terminal = kigi_ratatui_inline::Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)),
},
)
.expect("build terminal");
let mut cursor = CursorState::new();
draw_frame(&mut terminal, &mut cursor, render);
let first: Vec<u8> = rx.try_iter().flatten().collect();
assert!(!first.is_empty(), "first frame should emit bytes");
draw_frame(&mut terminal, &mut cursor, render);
let second: Vec<u8> = rx.try_iter().flatten().collect();
assert!(
second.is_empty(),
"idle (unchanged) frame must emit 0 bytes, got {}: {:?}",
second.len(),
String::from_utf8_lossy(&second),
);
}
/// `wait_drained` semantics: drained when `written` has caught up with
/// `queued` — immediately when nothing is pending, after the consumer
/// marks the frame written, and a bounded `false` when it never does.
/// This is the happens-before the suspend path relies on so no queued
/// frame can race a tty-taking `$EDITOR` / `$PAGER` child.
#[test]
fn writer_sync_drains_when_written_catches_queued() {
let sync = WriterSync::new();
assert!(sync.wait_drained(Duration::from_millis(1)));
sync.mark_queued();
assert!(!sync.is_drained());
assert!(!sync.wait_drained(Duration::from_millis(5)));
let consumer_sync = sync.clone();
let consumer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(10));
consumer_sync.mark_written();
});
assert!(sync.wait_drained(Duration::from_secs(5)));
consumer.join().expect("consumer thread");
}
/// A `TermWriter::flush` with buffered bytes marks the frame queued; the
/// writer-thread side marking it written restores the drained state.
#[test]
fn term_writer_flush_marks_queued() {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let mut writer = TermWriter::new(tx, sync.clone());
writer.flush().expect("flush");
assert!(sync.is_drained());
writer.write_all(b"frame bytes").expect("write");
writer.flush().expect("flush");
assert!(!sync.is_drained(), "queued frame not yet written");
assert_eq!(rx.try_recv().expect("frame on channel"), b"frame bytes");
sync.mark_written();
assert!(sync.is_drained());
}
fn state_hidden() -> CursorState {
CursorState { last_pos: None }
}
fn state_at(x: u16, y: u16) -> CursorState {
CursorState {
last_pos: Some((x, y)),
}
}
#[test]
fn hidden_no_changes_stays_hidden() {
let s = state_hidden();
assert_eq!(s.action(None, false), CursorAction::None);
}
#[test]
fn visible_same_pos_no_changes_preserves_blink() {
let s = state_at(5, 10);
assert_eq!(s.action(Some((5, 10)), false), CursorAction::None);
}
#[test]
fn visible_new_pos_no_changes_repositions() {
let s = state_at(5, 10);
assert_eq!(
s.action(Some((6, 10)), false),
CursorAction::Reposition(6, 10)
);
}
#[test]
fn hidden_with_changes_stays_hidden() {
let s = state_hidden();
assert_eq!(s.action(None, true), CursorAction::None);
}
#[test]
fn visible_same_pos_with_changes_repositions() {
let s = state_at(5, 10);
assert_eq!(
s.action(Some((5, 10)), true),
CursorAction::Reposition(5, 10)
);
}
#[test]
fn visible_new_pos_with_changes_repositions() {
let s = state_at(5, 10);
assert_eq!(
s.action(Some((8, 10)), true),
CursorAction::Reposition(8, 10)
);
}
#[test]
fn hidden_to_visible_shows() {
let s = state_hidden();
assert_eq!(s.action(Some((5, 10)), false), CursorAction::Show(5, 10));
}
#[test]
fn hidden_to_visible_with_changes_shows() {
let s = state_hidden();
assert_eq!(s.action(Some((5, 10)), true), CursorAction::Show(5, 10));
}
#[test]
fn visible_to_hidden_hides() {
let s = state_at(5, 10);
assert_eq!(s.action(None, false), CursorAction::Hide);
}
#[test]
fn visible_to_hidden_with_changes_hides() {
let s = state_at(5, 10);
assert_eq!(s.action(None, true), CursorAction::Hide);
}
#[test]
fn apply_show_updates_last_pos() {
let mut s = state_hidden();
let mut sink = Vec::new();
s.apply(CursorAction::Show(3, 7), &mut sink);
assert_eq!(s.last_pos, Some((3, 7)));
}
#[test]
fn apply_hide_clears_last_pos() {
let mut s = state_at(3, 7);
let mut sink = Vec::new();
s.apply(CursorAction::Hide, &mut sink);
assert_eq!(s.last_pos, None);
}
#[test]
fn apply_reposition_updates_last_pos() {
let mut s = state_at(3, 7);
let mut sink = Vec::new();
s.apply(CursorAction::Reposition(5, 9), &mut sink);
assert_eq!(s.last_pos, Some((5, 9)));
}
#[test]
fn apply_none_preserves_state() {
let mut s = state_at(3, 7);
let mut sink = Vec::new();
s.apply(CursorAction::None, &mut sink);
assert_eq!(s.last_pos, Some((3, 7)));
}
/// Verify the writer thread correctly round-trips multi-byte UTF-8
/// through the channel. This catches encoding issues where the writer
/// silently corrupts Braille/emoji/CJK characters.
#[test]
fn writer_thread_preserves_multibyte_utf8() {
let test_payload = "⣀⣾⠿⠛\u{e0a0}\u{1F600}";
let expected_bytes = test_payload.as_bytes().to_vec();
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let capture = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let capture2 = capture.clone();
let handle = std::thread::spawn(move || {
let mut buf = Vec::new();
while let Ok(data) = rx.recv() {
buf.extend_from_slice(&data);
}
*capture2.lock().unwrap() = buf;
});
tx.send(expected_bytes.clone()).unwrap();
drop(tx);
handle.join().unwrap();
let captured = capture.lock().unwrap();
assert_eq!(
*captured, expected_bytes,
"Writer thread corrupted multi-byte UTF-8 payload"
);
assert_eq!(
std::str::from_utf8(&captured).unwrap(),
test_payload,
"Round-tripped bytes do not decode to original UTF-8 string"
);
}
}
@@ -0,0 +1,169 @@
//! `/gboom` easter-egg overlay chrome (border, title, HUD bar).
//!
//! The game frame itself is rendered via post-flush kitty escape sequences
//! by the caller, matching the image/video viewer pattern.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::gboom::GboomHud;
use crate::render::safe_buf::SafeBuf;
/// Render the GBOOM popup chrome. Returns the popup `Rect`,
/// or `None` if the area is too small to play in.
pub fn render_gboom_overlay(
buf: &mut Buffer,
area: Rect,
hud: &GboomHud,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<Rect> {
if area.height < 8 || area.width < 30 {
return None;
}
crate::render::color::dim_area(buf, area, bg, 0.5);
// 90% centered popup, like the video viewer.
let popup_width = ((area.width as u32 * 90) / 100)
.max(30)
.min(area.width as u32) as u16;
let popup_height = ((area.height as u32 * 90) / 100)
.max(8)
.min(area.height as u32) as u16;
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_rect = Rect::new(popup_x, popup_y, popup_width, popup_height);
ratatui::widgets::Clear.render(popup_rect, buf);
buf.set_style(popup_rect, Style::default().fg(text_fg).bg(bg));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_fg).bg(bg))
.style(Style::default().bg(bg))
.render(popup_rect, buf);
// Title centered in the top border, in the iconic logo red.
let title = " GBOOM ";
let [r, g, b] = crate::gboom::GBOOM_RED;
let title_style = Style::default()
.fg(Color::Rgb(r, g, b))
.bg(bg)
.add_modifier(Modifier::BOLD);
let tw = title.len() as u16;
let tx = popup_rect.x + (popup_rect.width.saturating_sub(tw)) / 2;
buf.set_span_safe(tx, popup_rect.y, &Span::styled(title, title_style), tw);
// HUD on the bottom border row.
render_hud_bar(buf, popup_rect, hud, border_fg, bg);
Some(popup_rect)
}
/// Render the HUD on the popup's bottom border row:
/// `HP 100 · KILLS 0/8` left, controls hint right.
fn render_hud_bar(buf: &mut Buffer, popup_rect: Rect, hud: &GboomHud, dim_fg: Color, bg: Color) {
let bar_y = popup_rect.y + popup_rect.height.saturating_sub(1);
let inner_width = popup_rect.width.saturating_sub(2) as usize;
if inner_width <= 12 {
return;
}
// Health-bar semantics: green when comfortable, amber when hurting,
// GBOOM red when critical.
let hp_color = if hud.hp > 60 {
Color::Rgb(126, 200, 96)
} else if hud.hp > 30 {
Color::Rgb(235, 198, 82)
} else {
let [r, g, b] = crate::gboom::GBOOM_RED;
Color::Rgb(r, g, b)
};
let stats = format!(
" HP {:<3} \u{00b7} KILLS {}/{} ",
hud.hp, hud.kills, hud.total
);
// `chars().count()` not `len()`: the separator is multi-byte UTF-8 but
// every char here is a single display cell.
let stats_w = (stats.chars().count() as u16).min(inner_width as u16);
let line = Line::from(vec![Span::styled(
stats,
Style::default()
.fg(hp_color)
.bg(bg)
.add_modifier(Modifier::BOLD),
)]);
buf.set_line_safe(popup_rect.x + 1, bar_y, &line, stats_w);
let hint = if hud.playing {
" WASD/\u{2190}\u{2192} move \u{00b7} SPACE fire \u{00b7} ESC quit "
} else {
" ESC quit "
};
let hint_w = hint.chars().count() as u16;
if (stats_w + hint_w) as usize <= inner_width {
let hx = popup_rect.x + 1 + inner_width as u16 - hint_w;
buf.set_span_safe(
hx,
bar_y,
&Span::styled(hint, Style::default().fg(dim_fg).bg(bg)),
hint_w,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hud() -> GboomHud {
GboomHud {
hp: 100,
kills: 2,
total: 8,
playing: true,
}
}
#[test]
fn returns_none_when_area_too_small() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
assert!(
render_gboom_overlay(
&mut buf,
Rect::new(0, 0, 20, 5),
&hud(),
Color::Black,
Color::White,
Color::Gray,
)
.is_none()
);
}
#[test]
fn renders_popup_with_title_and_hud() {
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
let popup = render_gboom_overlay(
&mut buf,
area,
&hud(),
Color::Black,
Color::White,
Color::Gray,
)
.expect("popup should render");
assert!(popup.width >= 30);
let content: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(content.contains("GBOOM"), "title missing");
assert!(content.contains("KILLS"), "HUD missing");
}
}
@@ -0,0 +1,77 @@
//! Match-highlight overlay shared by the list pane and other search surfaces.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::render::wrapping::{
byte_offset_to_display_col, byte_range_to_row_cols, wrap_byte_ranges_matching,
};
/// Invert (REVERSED) the buffer cells covering every match of `re` in `text`.
///
/// Run as a post-pass after a line has been drawn, so matches are highlighted
/// regardless of the underlying colors.
///
/// - `area`: the pane area; `area.x` / `area.width` bound painting horizontally.
/// - `row_y`: buffer row of the line's first visible row.
/// - `viewport_bottom`: exclusive bottom row; wrapped rows at or below it stop.
/// - `skip`: leading wrapped rows of this line clipped above the viewport.
/// - `prefix_w`: display column where `text` begins (e.g. a line-number gutter).
/// - `text`: the plain text the regex runs against.
/// - `single_row`: the line occupies one buffer row (NoWrap, or any 1-row item).
#[allow(clippy::too_many_arguments)]
pub fn paint_match_highlights(
buf: &mut Buffer,
area: Rect,
row_y: u16,
viewport_bottom: u16,
skip: u16,
prefix_w: u16,
text: &str,
re: &regex::Regex,
single_row: bool,
) {
if text.is_empty() {
return;
}
if single_row {
for m in re.find_iter(text) {
let col_start = prefix_w as usize + byte_offset_to_display_col(text, m.start());
let col_end = prefix_w as usize + byte_offset_to_display_col(text, m.end());
for col in col_start..col_end {
let x = area.x + col as u16;
if x < area.x + area.width {
invert_cell(&mut buf[(x, row_y)]);
}
}
}
return;
}
let text_w = area.width.saturating_sub(prefix_w) as usize;
let ranges = wrap_byte_ranges_matching(text, text_w);
for m in re.find_iter(text) {
for seg in byte_range_to_row_cols(text, &ranges, m.start()..m.end()) {
if seg.row < skip as usize {
continue;
}
let y = row_y + (seg.row - skip as usize) as u16;
if y >= viewport_bottom {
break;
}
for col in seg.col_start..seg.col_end {
let x = area.x + prefix_w + col as u16;
if x < area.x + area.width {
invert_cell(&mut buf[(x, y)]);
}
}
}
}
}
/// Apply the terminal's REVERSED attribute so the fg/bg swap is native and
/// respects the user's theme.
fn invert_cell(cell: &mut ratatui::buffer::Cell) {
cell.modifier.insert(ratatui::style::Modifier::REVERSED);
}
@@ -0,0 +1,223 @@
//! Image preview overlay for prompt image chips.
//!
//! Renders a bordered popup when the cursor is on (or right after) an image
//! chip, or when the chip is hovered. Content follows a pure 2×2 matrix:
//!
//! | | Has filepath | No filepath |
//! |--------------------|---------------------------|----------------------------|
//! | **Pixels available** | Image + path footer | Image only |
//! | **Pixels unavailable** | Metadata + path | Metadata only |
//!
//! The prompt bar chip itself is always path-free (`[Image #N]`); paths
//! appear only here.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Widget, Wrap};
use crate::prompt_images::PastedImage;
use crate::terminal::image as terminal_image;
use crate::terminal::overlay;
mod content;
mod geometry;
use content::{
build_meta_line, format_bytes, format_mime, paint_path_line, truncate_path_for_overlay,
};
#[cfg(test)]
use geometry::ImagePlacement;
use geometry::{
MIN_BOX_WIDTH, MIN_META_BOX_HEIGHT, MIN_PIXEL_BOX_HEIGHT, overlay_geometry, plan_image_preview,
};
#[derive(Debug)]
struct ImageOverlayRender {
#[cfg(test)]
image_placement: Option<ImagePlacement>,
escapes: Option<overlay::Escapes>,
}
/// Render an image preview overlay and return any post-flush pixel escapes.
pub fn render_image_overlay(
buf: &mut Buffer,
area: Rect,
image: &PastedImage,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<overlay::Escapes> {
render_image_overlay_inner(buf, area, image, bg, text_fg, border_fg)
.and_then(|render| render.escapes)
}
fn render_image_overlay_inner(
buf: &mut Buffer,
area: Rect,
image: &PastedImage,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<ImageOverlayRender> {
if area.width < MIN_BOX_WIDTH {
return None;
}
let theme = crate::theme::Theme::current();
let protocol = terminal_image::detect_graphics_protocol();
let plan = plan_image_preview(image, protocol);
let min_height = if plan.show_pixels {
MIN_PIXEL_BOX_HEIGHT
} else {
MIN_META_BOX_HEIGHT
};
if area.height < min_height {
return None;
}
let geometry = overlay_geometry(
area,
plan.show_pixels,
plan.display_path.is_some(),
image.preview_dimensions().unwrap_or((640, 480)),
)?;
let overlay_rect = geometry.overlay_rect;
crate::render::color::dim_area(buf, area, theme.bg_base, 0.5);
ratatui::widgets::Clear.render(overlay_rect, buf);
buf.set_style(overlay_rect, Style::default().fg(text_fg).bg(bg));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_fg).bg(bg))
.style(Style::default().bg(bg));
let inner = block.inner(overlay_rect);
block.render(overlay_rect, buf);
let title_text = format!(" Image #{} ", image.display_number);
let meta = build_meta_line(image, plan.display_path);
let full_title = if meta.len() + title_text.len() + 6 < overlay_rect.width as usize {
format!("{}\u{2500} {} ", title_text, meta)
} else {
title_text.clone()
};
let title_style = Style::default()
.fg(text_fg)
.bg(bg)
.add_modifier(ratatui::style::Modifier::BOLD);
let title_width = full_title.len() as u16;
let title_x = overlay_rect.x + (overlay_rect.width.saturating_sub(title_width)) / 2;
buf.set_span(
title_x,
overlay_rect.y,
&Span::styled(&full_title, title_style),
title_width,
);
if inner.width == 0 || inner.height == 0 {
return Some(ImageOverlayRender {
#[cfg(test)]
image_placement: geometry.image_placement,
escapes: None,
});
}
// Reserve the footer so a pixel placement cannot cover the path.
let path_footer = plan.display_path.filter(|_| inner.height >= 2);
let image_inner = if let Some(path) = path_footer {
let footer_y = inner.y + inner.height - 1;
paint_path_line(buf, inner.x, footer_y, inner.width, path, text_fg, bg);
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
}
} else {
inner
};
if !plan.show_pixels {
let mut lines = Vec::new();
lines.push(Line::from(format!(
"Format: {}",
format_mime(&image.mime_type)
)));
if let Some((w, h)) = image.preview_dimensions() {
lines.push(Line::from(format!("Dimensions: {} x {}", w, h)));
}
let status = if image.preview.is_failed() {
Some("Preview unavailable")
} else if image.preview.is_pending() && protocol.supports_images() {
Some("Preview pending")
} else {
None
};
lines.push(Line::from(status.map(str::to_owned).unwrap_or_else(|| {
format!("Size: {}", format_bytes(image.byte_len))
})));
// Short boxes need the path in the body because no footer fits.
if path_footer.is_none()
&& let Some(path) = plan.display_path
{
lines.push(Line::from(format!(
"Path: {}",
truncate_path_for_overlay(&path.display().to_string(), inner.width as usize)
)));
}
let body = if path_footer.is_some() {
image_inner
} else {
inner
};
let paragraph = Paragraph::new(lines)
.style(Style::default().fg(text_fg).bg(bg))
.wrap(Wrap { trim: false });
paragraph.render(body, buf);
return Some(ImageOverlayRender {
#[cfg(test)]
image_placement: None,
escapes: None,
});
}
if image_inner.width > 0 && image_inner.height > 0 {
use crate::render::SafeBuf;
let loading = "Loading...";
let lw = loading.len() as u16;
let lx = image_inner.x + image_inner.width.saturating_sub(lw) / 2;
let ly = image_inner.y + image_inner.height / 2;
buf.set_span_safe(
lx,
ly,
&Span::styled(loading, Style::default().fg(text_fg).bg(bg)),
lw,
);
}
let escapes = geometry.image_placement.and_then(|placement| {
let (bytes, _) = image.preview.prepared()?;
overlay::static_image_for_protocol(
protocol,
bytes,
placement.cols,
placement.rows,
placement.x,
placement.y,
image.preview.identity(),
)
});
Some(ImageOverlayRender {
#[cfg(test)]
image_placement: geometry.image_placement,
escapes,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,87 @@
use std::path::Path;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use crate::prompt_images::PastedImage;
use crate::render::SafeBuf;
pub(super) fn paint_path_line(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
path: &Path,
text_fg: Color,
bg: Color,
) {
let raw = path.display().to_string();
let label = format!(
"Path: {}",
truncate_path_for_overlay(&raw, width.saturating_sub(6) as usize)
);
let clipped = crate::render::line_utils::truncate_str(&label, width as usize);
buf.set_span_safe(
x,
y,
&Span::styled(clipped, Style::default().fg(text_fg).bg(bg)),
width,
);
}
pub(super) fn build_meta_line(image: &PastedImage, display_path: Option<&Path>) -> String {
let mut parts = Vec::with_capacity(4);
parts.push(format_mime(&image.mime_type));
if let Some((width, height)) = image.preview_dimensions() {
parts.push(format!("{}x{}", width, height));
}
parts.push(format_bytes(image.byte_len));
if let Some(path) = display_path
&& let Some(name) = path.file_name()
{
parts.push(name.to_string_lossy().into_owned());
}
parts.join(" \u{00b7} ")
}
pub(super) fn format_mime(mime: &str) -> String {
match mime {
"image/png" => "PNG".into(),
"image/jpeg" => "JPEG".into(),
"image/tiff" => "TIFF".into(),
"image/gif" => "GIF".into(),
"image/webp" => "WebP".into(),
"image/bmp" => "BMP".into(),
other => other.into(),
}
}
pub(super) fn format_bytes(bytes: usize) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
pub(super) fn truncate_path_for_overlay(path: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let char_count = path.chars().count();
if char_count <= max_chars {
return path.to_owned();
}
if max_chars <= 3 {
return path.chars().take(max_chars).collect();
}
let keep = max_chars.saturating_sub(3) / 2;
let end_keep = max_chars.saturating_sub(3) - keep;
let chars: Vec<char> = path.chars().collect();
let head: String = chars[..keep].iter().collect();
let tail: String = chars[chars.len() - end_keep..].iter().collect();
format!("{head}...{tail}")
}
@@ -0,0 +1,101 @@
use std::path::Path;
use ratatui::layout::Rect;
use crate::prompt_images::PastedImage;
use crate::terminal::image::{self as terminal_image, GraphicsProtocol};
pub(super) const MIN_BOX_WIDTH: u16 = 28;
pub(super) const MIN_PIXEL_BOX_HEIGHT: u16 = 8;
pub(super) const MIN_META_BOX_HEIGHT: u16 = 6;
const META_PREVIEW_WIDTH_RATIO: f32 = 0.75;
const META_CONTENT_LINES: u16 = 4;
const META_BOX_CHROME_ROWS: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ImagePreviewPlan<'a> {
pub(super) show_pixels: bool,
pub(super) display_path: Option<&'a Path>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ImageOverlayGeometry {
pub(super) overlay_rect: Rect,
pub(super) image_placement: Option<ImagePlacement>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ImagePlacement {
pub(super) cols: u16,
pub(super) rows: u16,
pub(super) x: u16,
pub(super) y: u16,
}
pub(super) fn plan_image_preview(
image: &PastedImage,
protocol: GraphicsProtocol,
) -> ImagePreviewPlan<'_> {
ImagePreviewPlan {
show_pixels: protocol.supports_images() && image.preview.prepared().is_some(),
display_path: image.source_path.as_deref(),
}
}
pub(super) fn overlay_geometry(
area: Rect,
show_pixels: bool,
has_path: bool,
dimensions: (u32, u32),
) -> Option<ImageOverlayGeometry> {
let min_height = if show_pixels {
MIN_PIXEL_BOX_HEIGHT
} else {
MIN_META_BOX_HEIGHT
};
if area.width < MIN_BOX_WIDTH || area.height < min_height {
return None;
}
if show_pixels {
let footer_rows = u16::from(has_path);
let max_cols = area.width.saturating_sub(2).max(4);
let max_rows = area
.height
.saturating_sub(2)
.saturating_sub(footer_rows)
.max(2);
let (cols, rows) =
terminal_image::fit_image_to_cells(dimensions.0, dimensions.1, max_cols, max_rows);
let width = (cols.saturating_add(2)).clamp(MIN_BOX_WIDTH, area.width);
let height = (rows.saturating_add(2).saturating_add(footer_rows))
.clamp(MIN_PIXEL_BOX_HEIGHT, area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let inner_width = width.saturating_sub(2);
let inner_height = height.saturating_sub(2).saturating_sub(footer_rows);
return Some(ImageOverlayGeometry {
overlay_rect: Rect::new(x, y, width, height),
image_placement: Some(ImagePlacement {
cols,
rows,
x: x + 1 + inner_width.saturating_sub(cols) / 2,
y: y + 1 + inner_height.saturating_sub(rows) / 2,
}),
});
}
let width = ((area.width as f32) * META_PREVIEW_WIDTH_RATIO) as u16;
let width = width.clamp(MIN_BOX_WIDTH, area.width);
let height = (META_CONTENT_LINES + META_BOX_CHROME_ROWS)
.min(area.height)
.max(MIN_META_BOX_HEIGHT)
.min(area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height);
Some(ImageOverlayGeometry {
overlay_rect: Rect::new(x, y, width, height),
image_placement: None,
})
}
@@ -0,0 +1,201 @@
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use super::content::{format_bytes, format_mime};
use super::geometry::{overlay_geometry, plan_image_preview};
use super::*;
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
fn png_header() -> Vec<u8> {
vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
fn sample_image(path: Option<&str>, pixels: bool) -> PastedImage {
let encoded_bytes = pixels.then(png_header);
let preview = encoded_bytes
.as_ref()
.map(|bytes| {
crate::prompt_images::PromptImagePreview::ready_for_test(bytes.clone(), (640, 480))
})
.unwrap_or_default();
PastedImage {
element_id: kigi_ratatui_textarea::ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((640, 480)),
byte_len: 1536,
encoded_bytes: encoded_bytes.map(Into::into),
source_path: path.map(PathBuf::from),
staged_temp_path: None,
session_image_path: None,
preview,
}
}
fn render_to_string(image: &PastedImage, area: Rect) -> (Option<ImageOverlayRender>, String) {
let mut buf = Buffer::empty(area);
let render = render_image_overlay_inner(
&mut buf,
area,
image,
Color::Black,
Color::White,
Color::Gray,
);
let rendered = (area.y..area.y + area.height)
.map(|y| {
(area.x..area.x + area.width)
.filter_map(|x| buf.cell((x, y)).map(|cell| cell.symbol().to_owned()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
(render, rendered)
}
#[test]
fn plan_covers_pixels_by_path_matrix() {
for (protocol, path, pixels, expected_pixels, expected_path) in [
(
GraphicsProtocol::Kitty,
Some("/tmp/logo.png"),
true,
true,
Some(Path::new("/tmp/logo.png")),
),
(GraphicsProtocol::Kitty, None, true, true, None),
(
GraphicsProtocol::None,
Some("/tmp/logo.png"),
true,
false,
Some(Path::new("/tmp/logo.png")),
),
(GraphicsProtocol::None, None, true, false, None),
] {
let image = sample_image(path, pixels);
let plan = plan_image_preview(&image, protocol);
assert_eq!(plan.show_pixels, expected_pixels);
assert_eq!(plan.display_path, expected_path);
}
}
#[test]
fn plan_displays_only_user_visible_source_path() {
let mut image = sample_image(None, true);
image.source_path = Some(PathBuf::from("/Users/me/original.png"));
image.session_image_path = Some(PathBuf::from("/tmp/session/image-uuid.png"));
assert_eq!(
plan_image_preview(&image, GraphicsProtocol::None).display_path,
Some(Path::new("/Users/me/original.png"))
);
image.source_path = None;
assert!(
plan_image_preview(&image, GraphicsProtocol::None)
.display_path
.is_none()
);
}
#[test]
fn paint_pixels_with_path_returns_footer_and_exact_transmission() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
crate::terminal::overlay::reset_owner();
let image = sample_image(Some("/tmp/logo.png"), true);
let (render, text) = render_to_string(&image, Rect::new(10, 5, 60, 20));
let render = render.unwrap();
let placement = render.image_placement.unwrap();
let escapes = render.escapes.unwrap();
assert!(text.contains("Image #1"));
assert!(
text.contains("Path: /tmp/logo.png"),
"rendered footer missing path: {text:?}",
);
assert!(escapes.as_str().starts_with(&format!(
"\x1b[{};{}H",
placement.y + 1,
placement.x + 1
)));
assert!(
escapes
.as_str()
.contains(&format!("c={},r={}", placement.cols, placement.rows))
);
}
#[test]
fn paint_pixels_without_path_has_no_footer() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let (render, text) = render_to_string(&sample_image(None, true), Rect::new(0, 0, 60, 20));
assert!(render.unwrap().image_placement.is_some());
assert!(!text.contains("Path:"));
}
#[test]
fn paint_metadata_with_path_shows_all_fields() {
let _guard = set_protocol_for_test(GraphicsProtocol::None);
let (render, text) = render_to_string(
&sample_image(Some("/tmp/logo.png"), true),
Rect::new(0, 0, 60, 20),
);
assert!(render.unwrap().image_placement.is_none());
assert!(text.contains("Format: PNG"));
assert!(text.contains("Dimensions: 640 x 480"));
assert!(text.contains("Path:"));
assert!(text.contains("logo.png"));
}
#[test]
fn failed_preview_uses_stable_metadata_fallback() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let mut image = sample_image(Some("/tmp/photo.jpg"), false);
image.mime_type = "image/jpeg".into();
image.preview.mark_failed();
let (render, text) = render_to_string(&image, Rect::new(0, 0, 80, 30));
assert!(render.unwrap().image_placement.is_none());
assert!(text.contains("Format: JPEG"));
assert!(text.contains("Preview unavailable"));
assert!(!text.contains("Loading..."));
}
#[test]
fn geometry_keeps_metadata_compact_and_pixels_larger() {
let area = Rect::new(0, 0, 100, 40);
let metadata = overlay_geometry(area, false, true, (640, 480)).unwrap();
let pixels = overlay_geometry(area, true, true, (640, 480)).unwrap();
assert_eq!(
metadata.overlay_rect.y + metadata.overlay_rect.height,
area.y + area.height
);
assert!(metadata.overlay_rect.height <= 8);
assert!(
pixels.overlay_rect.height > metadata.overlay_rect.height
|| pixels.overlay_rect.width > metadata.overlay_rect.width
);
}
#[test]
fn geometry_honors_plan_specific_minima() {
assert!(overlay_geometry(Rect::new(0, 0, 20, 20), false, true, (640, 480)).is_none());
assert!(overlay_geometry(Rect::new(0, 0, 60, 7), true, false, (640, 480)).is_none());
for height in [6, 7] {
let geometry =
overlay_geometry(Rect::new(0, 0, 60, height), false, true, (640, 480)).unwrap();
assert_eq!(geometry.overlay_rect.height, 6);
}
}
#[test]
fn formatting_helpers_cover_known_and_unknown_values() {
assert_eq!(format_mime("image/png"), "PNG");
assert_eq!(
format_mime("application/octet-stream"),
"application/octet-stream"
);
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(2_500_000), "2.4 MB");
}
@@ -0,0 +1,605 @@
//! Line and string utility functions for ratatui text manipulation.
use ratatui::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub use super::tool_paths::{path_basename, path_for_tool_header, shorten_path};
/// Clone a borrowed ratatui `Line` into an owned `'static` line.
pub fn line_to_static(line: &Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.iter()
.map(|s| Span {
style: s.style,
content: std::borrow::Cow::Owned(s.content.to_string()),
})
.collect(),
}
}
/// Append owned copies of borrowed lines to `out`.
pub fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
for l in src {
out.push(line_to_static(l));
}
}
/// True for a character unsafe to render from untrusted/server text:
/// C0/C1 controls (the terminal-escape-injection vector) plus the Unicode
/// bidi-control and zero-width/format set (Trojan-Source spoofing) — U+061C,
/// U+200B200F, U+202A202E, U+2060206F, U+FEFF.
///
/// Shared by every untrusted-text strip/scrub site (chip labels, toast error
/// scrub, settings editor input) so the set never drifts between them.
pub fn is_unsafe_display_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{061C}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FEFF}'
)
}
/// Polyfill for nightly-only [`str::floor_char_boundary`].
/// Snaps a byte index down to the nearest char boundary.
pub fn floor_char_boundary(s: &str, index: usize) -> usize {
let index = index.min(s.len());
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Byte offset at which cumulative display width exceeds `max_width`.
/// Returns `s.len()` when the entire string fits.
pub fn byte_offset_at_width(s: &str, max_width: usize) -> usize {
let mut width = 0;
for (i, ch) in s.char_indices() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw > max_width {
return i;
}
width += cw;
}
s.len()
}
/// Truncate a string to fit within `max_width` display columns.
///
/// Uses Unicode-aware width measurement (handles CJK wide chars,
/// multi-byte UTF-8 like em-dash, etc.). If truncated, the last character
/// is replaced with `…` so the result fits within `max_width`.
///
/// Returns the original string (owned) if it already fits.
pub fn truncate_str(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let end = byte_offset_at_width(s, max_width);
let needs_ellipsis = end < s.len();
if needs_ellipsis && max_width > 1 {
// Back up one char to make room for '…' (1 display column).
let truncated_end = s[..end]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}", &s[..truncated_end])
} else if needs_ellipsis {
"".to_string()
} else {
s[..end].to_string()
}
}
/// Truncate a styled `Line` (multiple spans) to fit within `max_width` display columns.
///
/// Walks spans left-to-right, consuming width budget. When the budget is
/// exhausted mid-span, that span is truncated and `…` is appended. Spans
/// beyond the budget are dropped. All styles are preserved.
///
/// Returns the line unchanged if it already fits.
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from(vec![]);
}
let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total <= max_width {
return line;
}
// Need room for the ellipsis (1 column).
let budget = max_width.saturating_sub(1);
let mut used = 0usize;
let mut out: Vec<Span<'static>> = Vec::new();
for span in line.spans {
let sw = span.content.width();
if used + sw <= budget {
// Entire span fits.
used += sw;
out.push(span);
} else {
// Partial fit — truncate this span.
let remaining = budget - used;
if remaining > 0 {
let truncated = take_width(&span.content, remaining);
out.push(Span::styled(truncated, span.style));
}
// Append ellipsis with the same style as the last span.
let ellipsis_style = out.last().map(|s| s.style).unwrap_or_default();
out.push(Span::styled("\u{2026}", ellipsis_style));
return Line::from(out);
}
}
// Shouldn't reach here (total > max_width checked above), but be safe.
Line::from(out)
}
/// Clip or pad a styled `Line` to exactly `width` display columns.
///
/// Wider lines are clipped on grapheme boundaries (a multi-`char` grapheme like
/// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are padded with
/// trailing spaces. This keeps a rendered row "self-owning" — the app writes a
/// real cell in every column, so a terminal drawing a glyph wider than the app
/// measured cannot strand a stale cell past the row (the markdown-table ghost
/// glyph bug). Width uses [`UnicodeWidthStr`], matching the table layout.
///
/// `width` must be a bounded display width: the pad branch allocates
/// `width - total` spaces.
pub fn fit_line_to_width<'a>(line: Line<'a>, width: usize) -> Line<'a> {
let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total == width {
return line;
}
let Line {
style,
alignment,
mut spans,
} = line;
if total < width {
spans.push(Span::raw(" ".repeat(width - total)));
return Line {
style,
alignment,
spans,
};
}
// Wider than width: clip on grapheme boundaries, no ellipsis.
let mut out: Vec<Span<'a>> = Vec::new();
let mut used = 0usize;
for span in spans {
let sw = span.content.width();
if used + sw <= width {
used += sw;
out.push(span);
if used == width {
break;
}
continue;
}
// This span straddles the boundary — take whole graphemes that fit.
let remaining = width - used;
let mut taken = String::new();
let mut taken_width = 0usize;
for g in span.content.graphemes(true) {
let gw = g.width();
if taken_width + gw > remaining {
break;
}
taken_width += gw;
taken.push_str(g);
}
if !taken.is_empty() {
out.push(Span::styled(taken, span.style));
used += taken_width;
}
// A straddling wide grapheme leaves a 1-column gap; pad it.
if used < width {
out.push(Span::raw(" ".repeat(width - used)));
}
break;
}
Line {
style,
alignment,
spans: out,
}
}
/// Take the first `n` display columns from a string.
fn take_width(s: &str, n: usize) -> String {
let mut width = 0;
let mut end = s.len();
for (i, ch) in s.char_indices() {
let cw = ch.width().unwrap_or(0);
if width + cw > n {
end = i;
break;
}
width += cw;
}
s[..end].to_string()
}
/// Cascade-truncate multiple text elements to fit within `avail` display columns.
///
/// Returns `(type, description, activity, meta)` truncated to fit.
/// Priority (highest first): type, activity, meta. Description is truncated
/// first. If overhead (type + activity + meta) >= avail, description is dropped
/// and the remaining elements are cascaded: meta is dropped first, then
/// activity is truncated, then type.
pub fn cascade_truncate(
avail: usize,
type_text: &str,
description: &str,
activity_text: &str,
meta_text: &str,
) -> (String, String, String, String) {
let overhead = type_text.width() + activity_text.width() + meta_text.width();
if overhead <= avail {
let desc_max = avail - overhead;
(
type_text.to_string(),
truncate_str(description, desc_max),
activity_text.to_string(),
meta_text.to_string(),
)
} else {
let mut budget = avail;
let td = if type_text.width() <= budget {
budget -= type_text.width();
type_text.to_string()
} else {
let s = truncate_str(type_text, budget);
budget = 0;
s
};
let ad = if budget == 0 {
String::new()
} else if activity_text.width() <= budget {
budget -= activity_text.width();
activity_text.to_string()
} else {
let s = truncate_str(activity_text, budget);
budget = 0;
s
};
let md = if budget == 0 {
String::new()
} else if meta_text.width() <= budget {
meta_text.to_string()
} else {
truncate_str(meta_text, budget)
};
(td, String::new(), ad, md)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_unsafe_display_char_covers_controls_and_bidi_format() {
// Safe: ordinary printable text (incl. legitimate RTL letters).
for c in ['a', ' ', '/', '\u{00e9}', '\u{05d0}'] {
assert!(!is_unsafe_display_char(c), "{c:?} must be safe");
}
// Unsafe: C0/C1 controls + the full bidi-control / zero-width set.
for c in [
'\u{1b}', '\n', '\t', '\u{061C}', '\u{200B}', '\u{200F}', '\u{202E}', '\u{2066}',
'\u{2069}', '\u{206F}', '\u{FEFF}',
] {
assert!(
is_unsafe_display_char(c),
"{:#06x} must be unsafe",
c as u32
);
}
}
#[test]
fn truncate_str_fits() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello", 5), "hello");
}
#[test]
fn truncate_str_truncates() {
assert_eq!(truncate_str("hello world!", 5), "hell…");
assert_eq!(truncate_str("abcdef", 4), "abc…");
}
#[test]
fn truncate_str_empty_and_zero() {
assert_eq!(truncate_str("hello", 0), "");
assert_eq!(truncate_str("", 5), "");
}
#[test]
fn truncate_str_width_1() {
assert_eq!(truncate_str("hello", 1), "");
assert_eq!(truncate_str("x", 1), "x");
}
#[test]
fn truncate_str_multibyte() {
// em-dash is 1 display column but 3 bytes
let s = "hello — world";
let result = truncate_str(s, 8);
assert!(result.ends_with('…'));
assert!(result.len() <= 12); // safe byte length
}
// ── truncate_line tests ─────────────────────────────────────────
#[test]
fn truncate_line_fits() {
let line = Line::from(vec![Span::raw("Hello "), Span::raw("world")]);
let result = truncate_line(line, 20);
assert_eq!(result.spans.len(), 2);
assert_eq!(result.spans[0].content.as_ref(), "Hello ");
assert_eq!(result.spans[1].content.as_ref(), "world");
}
#[test]
fn truncate_line_cuts_mid_span() {
let line = Line::from(vec![
Span::raw("Edit "),
Span::raw("very/long/path/to/file.rs"),
]);
// Total = 29, budget = 15 → "Edit very/long…"
let result = truncate_line(line, 15);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with('\u{2026}'));
assert!(text.width() <= 15);
}
#[test]
fn truncate_line_drops_later_spans() {
let line = Line::from(vec![
Span::raw("Search "),
Span::raw("pattern"),
Span::raw(" in "),
Span::raw("path"),
Span::raw(" (5 matches)"),
]);
let result = truncate_line(line, 18);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with('\u{2026}'));
assert!(text.width() <= 18);
}
#[test]
fn truncate_line_zero_width() {
let line = Line::from(vec![Span::raw("hello")]);
let result = truncate_line(line, 0);
assert!(result.spans.is_empty());
}
// ── fit_line_to_width tests ─────────────────────────────────────
fn line_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn fit_line_pads_short_line() {
let line = Line::from(vec![Span::raw("│ a │")]);
let out = fit_line_to_width(line, 10);
assert_eq!(line_text(&out).width(), 10);
assert_eq!(line_text(&out), "│ a │ ");
}
#[test]
fn fit_line_exact_width_unchanged() {
let line = Line::from(vec![Span::raw("hello")]);
let out = fit_line_to_width(line, 5);
assert_eq!(out.spans.len(), 1);
assert_eq!(line_text(&out), "hello");
}
#[test]
fn fit_line_clips_long_line_no_ellipsis() {
let line = Line::from(vec![Span::raw("│ Column A │ Column B │")]);
let out = fit_line_to_width(line, 8);
assert_eq!(line_text(&out).width(), 8);
assert_eq!(line_text(&out), "│ Column");
}
#[test]
fn fit_line_does_not_split_emoji_grapheme() {
// a(1)+b(1)+⚠️(2) = 4. Clipping to 3 must drop the width-2 grapheme
// whole (never split it) and pad → "ab" + 1 space.
let line = Line::from(vec![Span::raw("ab\u{26A0}\u{FE0F}")]);
let out = fit_line_to_width(line, 3);
assert_eq!(line_text(&out).width(), 3);
assert_eq!(line_text(&out), "ab ");
}
#[test]
fn fit_line_clips_grapheme_straddle_in_later_span() {
// The straddle happens in a later span: keep "ab", then 1 col left →
// ⚠️ (width 2) won't fit → dropped whole and padded.
let line = Line::from(vec![Span::raw("ab"), Span::raw("\u{26A0}\u{FE0F}cd")]);
let out = fit_line_to_width(line, 3);
assert_eq!(line_text(&out).width(), 3);
assert_eq!(line_text(&out), "ab ");
}
#[test]
fn fit_line_drops_subsequent_spans_after_clip() {
let line = Line::from(vec![
Span::raw("hello"),
Span::raw(" world"),
Span::raw("!!!"),
]);
let out = fit_line_to_width(line, 5);
assert_eq!(line_text(&out), "hello");
// The straddling/later spans must be dropped entirely.
assert_eq!(out.spans.len(), 1);
}
#[test]
fn fit_line_takes_partial_of_later_span() {
let line = Line::from(vec![Span::raw("ab"), Span::raw("cdef")]);
let out = fit_line_to_width(line, 4);
assert_eq!(line_text(&out), "abcd");
assert_eq!(line_text(&out).width(), 4);
}
#[test]
fn fit_line_zero_width_returns_empty() {
let line = Line::from(vec![Span::raw("│ a │")]);
let out = fit_line_to_width(line, 0);
assert_eq!(line_text(&out), "");
assert_eq!(line_text(&out).width(), 0);
}
#[test]
fn fit_line_preserves_span_styles_when_padding() {
let bold = ratatui::style::Style::new().add_modifier(ratatui::style::Modifier::BOLD);
let line = Line::from(vec![Span::styled("hi", bold)]);
let out = fit_line_to_width(line, 5);
assert_eq!(line_text(&out).width(), 5);
assert!(
out.spans[0]
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
}
#[test]
fn legacy_tool_path_api_remains_available_at_line_utils_path() {
assert_eq!(shorten_path("verylongfilename.rs", 10), "verylongf…");
assert_eq!(path_basename("/repo/src/main.rs", 80), "main.rs");
assert_eq!(
path_for_tool_header("/repo/src/main.rs", Some(80), "Read ".len()),
"main.rs"
);
assert_eq!(
path_for_tool_header("/repo/src/main.rs", None, "Read ".len()),
"/repo/src/main.rs"
);
}
// ── cascade_truncate tests ────────────────────────────────────
#[test]
fn cascade_truncate_all_fit() {
let (t, d, a, m) =
cascade_truncate(50, "type ", "description", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "description");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " meta");
}
#[test]
fn cascade_truncate_desc_truncated() {
let (t, d, a, m) = cascade_truncate(
25,
"type ",
"long description here",
" \u{2014} running",
" meta",
);
assert_eq!(t, "type ");
assert_eq!(d, "lo\u{2026}");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " meta");
}
#[test]
fn cascade_truncate_desc_gone_meta_truncated() {
// overhead = 6+10+6 = 22 > avail 20 → desc gone, type 6 + activity 10 + meta truncated to 4
let (t, d, a, m) = cascade_truncate(20, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " m\u{2026}");
}
#[test]
fn cascade_truncate_meta_and_activity_gone() {
// avail=8, type=6 fits (budget=2), activity truncated to 2, meta gone
let (t, d, a, m) = cascade_truncate(8, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "");
assert_eq!(a, " \u{2026}");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_type_truncated() {
let (t, d, a, m) = cascade_truncate(3, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "ty\u{2026}");
assert_eq!(d, "");
assert_eq!(a, "");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_zero_avail() {
let (t, d, a, m) = cascade_truncate(0, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "");
assert_eq!(d, "");
assert_eq!(a, "");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_unicode() {
// ✗ = 1 display column; — = 1 display column
let (t, d, a, m) = cascade_truncate(10, "\u{2717} ", "description", " \u{2014} run", "");
assert_eq!(t, "\u{2717} ");
assert_eq!(d, "\u{2026}");
assert_eq!(a, " \u{2014} run");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_overhead_equals_avail() {
// overhead exactly equals avail → desc empty, everything else fits
let (t, d, a, m) = cascade_truncate(22, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " meta");
}
#[test]
fn cascade_truncate_avail_one() {
let (t, d, a, m) = cascade_truncate(1, "type", "desc", "act", "meta");
assert_eq!(t, "\u{2026}");
assert_eq!((d.as_str(), a.as_str(), m.as_str()), ("", "", ""));
}
#[test]
fn cascade_truncate_all_empty() {
let (t, d, a, m) = cascade_truncate(10, "", "", "", "");
assert_eq!(
(t.as_str(), d.as_str(), a.as_str(), m.as_str()),
("", "", "", "")
);
}
}
@@ -0,0 +1,22 @@
//! Low-level rendering utilities.
//!
//! Generic rendering primitives used by the scrollback and viewport.
pub mod color;
pub mod draw;
pub mod gboom_overlay;
pub mod highlight;
pub mod image_overlay;
pub mod line_utils;
pub mod osc8;
pub mod preview_overlay;
pub mod renderable;
pub mod scrollbar;
pub mod terminal_output;
pub mod tool_paths;
pub mod video_overlay;
pub mod wrapping;
pub use image_overlay::render_image_overlay;
pub use preview_overlay::{PreviewConfig, PreviewStyle, render_preview_overlay};
pub mod safe_buf;
pub use renderable::Renderable;
pub use safe_buf::SafeBuf;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,604 @@
//! Multiline preview overlay widget.
//!
//! Renders a bordered popup showing a preview of multiline content.
//! Shows first N and last N lines with a `⋮` separator when content
//! exceeds the preview limit.
//!
//! Used for:
//! - Paste element previews in the prompt widget
//! - Queue item previews in the queue pane
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget};
use super::line_utils::{truncate_line, truncate_str};
use super::safe_buf::SafeBuf;
// ---------------------------------------------------------------------------
// PreviewStyle — configurable colors
// ---------------------------------------------------------------------------
/// Visual styling for the preview overlay.
#[derive(Debug, Clone, Copy)]
pub struct PreviewStyle {
/// Background color for the entire overlay box.
pub bg: Color,
/// Foreground color for content text.
pub text_fg: Color,
/// Foreground color for the border and dots separator.
pub border_fg: Color,
}
impl PreviewStyle {
/// Create a style with explicit colors.
pub fn new(bg: Color, text_fg: Color, border_fg: Color) -> Self {
Self {
bg,
text_fg,
border_fg,
}
}
}
// ---------------------------------------------------------------------------
// PreviewConfig — layout configuration
// ---------------------------------------------------------------------------
/// Layout configuration for the preview overlay.
#[derive(Debug, Clone)]
pub struct PreviewConfig {
/// Number of lines to show from the top and bottom when truncating.
/// If content has more than `preview_lines * 2` lines, shows first N,
/// dots separator, and last N lines.
pub preview_lines: usize,
/// Width of the overlay as a fraction of the available width (0.0 - 1.0).
/// Default: 0.75 (3/4 of available width).
pub width_ratio: f32,
/// Vertical gap between the overlay's bottom border and the anchor point.
/// 0 = overlay sits flush against the anchor.
pub bottom_gap: u16,
/// Minimum width for the overlay. Below this, the overlay won't render.
pub min_width: u16,
/// Minimum height for the overlay area. Below this, the overlay won't render.
pub min_height: u16,
/// Optional one-line hint painted into the bottom border row, e.g.
/// `╰─ enter to expand ────╯`. Costs no content row; skipped when the
/// box is too narrow to fit readable text. `None` (the default)
/// leaves the plain border.
pub hint: Option<Line<'static>>,
}
impl Default for PreviewConfig {
fn default() -> Self {
Self {
preview_lines: 3,
width_ratio: 0.75,
bottom_gap: 0,
min_width: 20,
min_height: 5,
hint: None,
}
}
}
// ---------------------------------------------------------------------------
// render_preview_overlay — main rendering function
// ---------------------------------------------------------------------------
/// Render a multiline preview overlay.
///
/// The overlay is anchored at the bottom of `area`, showing a bordered box
/// with the content preview. If content exceeds `config.preview_lines * 2`
/// lines, shows first N lines, a `⋮ (X more lines)` separator, and last N lines.
///
/// # Arguments
///
/// * `buf` - The buffer to render into
/// * `area` - The available area for the overlay (anchored at bottom)
/// * `content` - The multiline text content to preview
/// * `style` - Visual styling (colors)
/// * `config` - Layout configuration
///
/// # Returns
///
/// The actual `Rect` where the overlay was rendered, or `None` if the overlay
/// couldn't be rendered (area too small, content empty).
pub fn render_preview_overlay(
buf: &mut Buffer,
area: Rect,
content: &str,
style: PreviewStyle,
config: PreviewConfig,
) -> Option<Rect> {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
// Don't render if content is empty or area is too small
if total == 0 || area.height < config.min_height || area.width < config.min_width {
return None;
}
// Calculate content layout
let needs_dots = total > config.preview_lines * 2;
let content_lines: usize = if needs_dots {
config.preview_lines * 2 + 1 // top + dots + bottom
} else {
total
};
// Box dimensions: border(1) + content + border(1)
let box_height = (content_lines as u16 + 2).min(area.height);
let box_width = ((area.width as f32) * config.width_ratio) as u16;
// Anchor at bottom of area
let anchor_bottom = area.y + area.height - config.bottom_gap;
let box_x = area.x + (area.width.saturating_sub(box_width)) / 2;
let box_y = anchor_bottom.saturating_sub(box_height);
let box_area = Rect {
x: box_x,
y: box_y,
width: box_width,
height: box_height,
};
// Build the bordered block
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(style.border_fg))
.style(Style::default().bg(style.bg));
let inner = block.inner(box_area);
// Clear background - fill every cell so underlying content doesn't bleed through
Clear.render(box_area, buf);
buf.set_style(box_area, Style::default().bg(style.bg));
// Render the border
block.render(box_area, buf);
// Render content
let text_style = Style::default().fg(style.text_fg).bg(style.bg);
let dots_style = Style::default().fg(style.border_fg).bg(style.bg);
render_content_lines(
buf,
inner,
&lines,
needs_dots,
config.preview_lines,
text_style,
dots_style,
);
// Hint lives in the bottom border row: costs no content row, and the
// border interruption reads as a label even when a theme aliases the
// hint palette to the border/content colors.
if let Some(hint) = &config.hint {
render_border_hint(buf, box_area, hint, style.bg);
}
Some(box_area)
}
/// Render the content lines into the inner area.
fn render_content_lines(
buf: &mut Buffer,
inner: Rect,
lines: &[&str],
needs_dots: bool,
preview_lines: usize,
text_style: Style,
dots_style: Style,
) {
let total = lines.len();
let mut row = 0u16;
let max_rows = inner.height;
if needs_dots {
// Top lines
for line in lines.iter().take(preview_lines) {
if row >= max_rows {
break;
}
render_line(buf, inner.x, inner.y + row, inner.width, line, text_style);
row += 1;
}
// Dots separator
if row < max_rows {
let omitted = total - preview_lines * 2;
let dots_text = format!("⋮ ({omitted} more lines)");
buf.set_span_safe(
inner.x,
inner.y + row,
&Span::styled(dots_text, dots_style),
inner.width,
);
row += 1;
}
// Bottom lines
let start = total.saturating_sub(preview_lines);
for line in lines.iter().skip(start) {
if row >= max_rows {
break;
}
render_line(buf, inner.x, inner.y + row, inner.width, line, text_style);
row += 1;
}
} else {
// Show all lines
for line in lines {
if row >= max_rows {
break;
}
render_line(buf, inner.x, inner.y + row, inner.width, line, text_style);
row += 1;
}
}
}
/// Render a single truncated line.
#[inline]
fn render_line(buf: &mut Buffer, x: u16, y: u16, width: u16, line: &str, style: Style) {
let truncated = truncate_str(line, width as usize);
buf.set_span_safe(x, y, &Span::styled(truncated, style), width);
}
/// Paint the hint into the bottom border row, left-aligned after the
/// corner and one dash, padded with a space on each side so the text
/// stands off the dashes: `╰─ enter to expand ────╯`. The corners and
/// one dash per side are never overwritten. Skipped entirely when the
/// box is too narrow for readable text.
fn render_border_hint(buf: &mut Buffer, box_area: Rect, hint: &Line<'static>, bg: Color) {
// Chrome around the text: corners (2) + one dash each side (2) + pads (2).
const CHROME: u16 = 6;
// Below this the truncated text is noise — keep the plain border.
const MIN_TEXT_WIDTH: u16 = 8;
let text_width = box_area.width.saturating_sub(CHROME);
if text_width < MIN_TEXT_WIDTH {
return;
}
let mut line = truncate_line(hint.clone(), text_width as usize);
// The box bg wins so the hint sits on the border row fill.
for span in &mut line.spans {
span.style = span.style.bg(bg);
}
let pad = Span::styled(" ", Style::default().bg(bg));
let mut spans = vec![pad.clone()];
spans.append(&mut line.spans);
spans.push(pad);
let y = box_area.y + box_area.height - 1;
buf.set_line_safe(box_area.x + 2, y, &Line::from(spans), box_area.width - 4);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn test_style() -> PreviewStyle {
PreviewStyle::new(
Color::Indexed(234), // grayscale 28 — dark bg
Color::Indexed(189), // (215,215,255) — light text
Color::Indexed(60), // (95,95,135) — dim border
)
}
#[test]
fn test_empty_content_returns_none() {
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 80, 20),
"",
test_style(),
PreviewConfig::default(),
);
assert!(result.is_none());
}
#[test]
fn test_area_too_small_returns_none() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 3));
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 10, 3), // below min_height=5
"hello\nworld",
test_style(),
PreviewConfig::default(),
);
assert!(result.is_none());
}
#[test]
fn test_single_line_renders() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 10),
"single line",
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
let rect = result.unwrap();
// Box should be 3 rows: border + 1 content + border
assert_eq!(rect.height, 3);
}
#[test]
fn test_few_lines_no_dots() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 15));
let content = "line1\nline2\nline3\nline4";
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 15),
content,
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
let rect = result.unwrap();
// 4 lines + 2 borders = 6 rows
assert_eq!(rect.height, 6);
// Should NOT contain dots separator (4 lines <= 6 = preview_lines * 2)
let buf_str = buffer_to_string(&buf);
assert!(!buf_str.contains(""), "Should not have dots: {}", buf_str);
}
#[test]
fn test_many_lines_shows_dots() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 15));
let content = (1..=10)
.map(|i| format!("line{}", i))
.collect::<Vec<_>>()
.join("\n");
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 15),
&content,
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
// Should contain dots separator (10 lines > 6 = preview_lines * 2)
let buf_str = buffer_to_string(&buf);
assert!(buf_str.contains(""), "Should have dots: {}", buf_str);
assert!(
buf_str.contains("4 more lines"),
"Should show omitted count: {}",
buf_str
);
}
#[test]
fn test_custom_preview_lines() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 20));
let content = (1..=20)
.map(|i| format!("line{}", i))
.collect::<Vec<_>>()
.join("\n");
let config = PreviewConfig {
preview_lines: 5,
..Default::default()
};
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 20),
&content,
test_style(),
config,
);
assert!(result.is_some());
let rect = result.unwrap();
// 5 top + 1 dots + 5 bottom + 2 borders = 13 rows
assert_eq!(rect.height, 13);
let buf_str = buffer_to_string(&buf);
assert!(
buf_str.contains("10 more lines"),
"Should show 10 omitted: {}",
buf_str
);
}
#[test]
fn test_width_ratio() {
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 10));
let config = PreviewConfig {
width_ratio: 0.5,
..Default::default()
};
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 100, 10),
"hello",
test_style(),
config,
);
assert!(result.is_some());
let rect = result.unwrap();
assert_eq!(rect.width, 50); // 100 * 0.5 = 50
}
#[test]
fn test_long_line_truncated() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let long_line = "a".repeat(100);
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 30, 10),
&long_line,
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
// Content should be truncated with ellipsis
let buf_str = buffer_to_string(&buf);
assert!(
buf_str.contains(""),
"Long line should be truncated: {}",
buf_str
);
}
fn test_hint() -> Line<'static> {
Line::from(vec![
Span::styled("enter", Style::default()),
Span::styled(" to expand", Style::default()),
])
}
/// Helper: one buffer row as a string.
fn row_to_string(buf: &Buffer, y: u16) -> String {
(0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect()
}
/// Assert the box's bottom border row keeps both rounded corners.
fn assert_corners(buf: &Buffer, rect: Rect) {
let y = rect.y + rect.height - 1;
assert_eq!(buf[(rect.x, y)].symbol(), "");
assert_eq!(buf[(rect.x + rect.width - 1, y)].symbol(), "");
}
#[test]
fn test_hint_renders_in_bottom_border() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let config = PreviewConfig {
hint: Some(test_hint()),
..Default::default()
};
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 10),
"hello\nworld",
test_style(),
config,
)
.unwrap();
// The hint costs no row: 2 content + 2 borders.
assert_eq!(rect.height, 4);
assert!(row_to_string(&buf, rect.y + 1).contains("hello"));
assert!(row_to_string(&buf, rect.y + 2).contains("world"));
let bottom = row_to_string(&buf, rect.y + rect.height - 1);
assert!(bottom.contains("enter to expand"), "{bottom}");
assert_corners(&buf, rect);
}
#[test]
fn test_hint_costs_no_height() {
let area = Rect::new(0, 0, 40, 10);
let content = "l1\nl2\nl3";
let mut buf_hint = Buffer::empty(area);
let config = PreviewConfig {
hint: Some(test_hint()),
..Default::default()
};
let with_hint = render_preview_overlay(&mut buf_hint, area, content, test_style(), config);
let mut buf_plain = Buffer::empty(area);
let without = render_preview_overlay(
&mut buf_plain,
area,
content,
test_style(),
PreviewConfig::default(),
);
assert!(with_hint.is_some());
assert_eq!(with_hint, without, "hint must not change the box geometry");
}
#[test]
fn test_hint_none_keeps_plain_border() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 10),
"hello\nworld",
test_style(),
PreviewConfig::default(),
)
.unwrap();
assert_corners(&buf, rect);
// Every cell between the corners is a border dash.
let y = rect.y + rect.height - 1;
for x in rect.x + 1..rect.x + rect.width - 1 {
assert_eq!(buf[(x, y)].symbol(), "", "col {x}");
}
}
#[test]
fn test_hint_truncated_at_narrow_width() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let config = PreviewConfig {
hint: Some(Line::from("a very long hint that cannot possibly fit")),
..Default::default()
};
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 30, 10),
"hi",
test_style(),
config,
)
.unwrap();
let bottom = row_to_string(&buf, rect.y + rect.height - 1);
assert!(bottom.contains(""), "hint should ellipsize: {bottom}");
assert!(!bottom.contains("possibly"), "{bottom}");
assert_corners(&buf, rect);
}
#[test]
fn test_hint_skipped_when_ultra_narrow() {
// Box of 12 cells leaves 6 for text — below the readability floor,
// so the border stays plain.
let mut buf = Buffer::empty(Rect::new(0, 0, 16, 10));
let config = PreviewConfig {
hint: Some(test_hint()),
min_width: 10,
..Default::default()
};
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 16, 10),
"hi",
test_style(),
config,
)
.unwrap();
let y = rect.y + rect.height - 1;
for x in rect.x + 1..rect.x + rect.width - 1 {
assert_eq!(buf[(x, y)].symbol(), "", "col {x}");
}
assert_corners(&buf, rect);
}
/// Helper: convert buffer to string for assertions.
fn buffer_to_string(buf: &Buffer) -> String {
let mut s = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
s.push_str(buf[(x, y)].symbol());
}
s.push('\n');
}
s
}
}
@@ -0,0 +1,209 @@
//! The [`Renderable`] trait for self-rendering content.
//!
//! This is the core rendering abstraction for virtualized scrolling.
//! Types implementing `Renderable` know:
//! - How tall they are at a given width (`desired_height`)
//! - How to render themselves into a buffer area (`render`)
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::WidgetRef;
use std::sync::Arc;
/// Trait for content that can render itself.
///
/// Implementors must be able to:
/// - Report their desired height at a given width
/// - Render into a provided rectangular area
///
/// The trait is object-safe to allow heterogeneous collections.
pub trait Renderable {
/// Render content into the given area.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Height needed at this width in lines.
///
/// This should be efficient (ideally O(1)) as it may be called
/// frequently during scroll position calculations.
fn desired_height(&self, width: u16) -> u16;
}
/// Owned or borrowed renderable item for composition.
pub enum RenderableItem<'a> {
Owned(Box<dyn Renderable + 'a>),
Borrowed(&'a dyn Renderable),
}
impl<'a> Renderable for RenderableItem<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
match self {
RenderableItem::Owned(child) => child.render(area, buf),
RenderableItem::Borrowed(child) => child.render(area, buf),
}
}
fn desired_height(&self, width: u16) -> u16 {
match self {
RenderableItem::Owned(child) => child.desired_height(width),
RenderableItem::Borrowed(child) => child.desired_height(width),
}
}
}
impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
fn from(value: Box<dyn Renderable + 'a>) -> Self {
RenderableItem::Owned(value)
}
}
// ============================================================================
// Standard Implementations
// ============================================================================
/// Unit type renders as nothing (0 height).
impl Renderable for () {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
/// String slices render as a single line.
impl Renderable for &str {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
/// Owned strings render as a single line.
impl Renderable for String {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_str().render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
/// Spans render as a single line.
impl<'a> Renderable for Span<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
/// Lines render as a single line (no wrapping).
impl<'a> Renderable for Line<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(self, area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
// Note: Paragraph::line_count is unstable in ratatui, so we don't implement
// Renderable for Paragraph directly. Users should wrap text in custom types
// that handle their own height calculation.
/// Option<R> renders the inner value or nothing.
impl<R: Renderable> Renderable for Option<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
if let Some(renderable) = self {
renderable.render(area, buf);
}
}
fn desired_height(&self, width: u16) -> u16 {
if let Some(renderable) = self {
renderable.desired_height(width)
} else {
0
}
}
}
/// Arc<R> delegates to inner.
impl<R: Renderable> Renderable for Arc<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_ref().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_ref().desired_height(width)
}
}
/// Box<R> delegates to inner.
impl<R: Renderable + ?Sized> Renderable for Box<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_ref().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_ref().desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_has_zero_height() {
assert_eq!(().desired_height(80), 0);
}
#[test]
fn str_has_height_one() {
assert_eq!("hello".desired_height(80), 1);
}
#[test]
fn string_has_height_one() {
assert_eq!(String::from("hello").desired_height(80), 1);
}
#[test]
fn line_has_height_one() {
let line = Line::from("hello");
assert_eq!(line.desired_height(80), 1);
}
#[test]
fn span_has_height_one() {
let span = Span::raw("hello");
assert_eq!(span.desired_height(80), 1);
}
#[test]
fn option_none_has_zero_height() {
let opt: Option<&str> = None;
assert_eq!(opt.desired_height(80), 0);
}
#[test]
fn option_some_delegates_height() {
let opt: Option<&str> = Some("hello");
assert_eq!(opt.desired_height(80), 1);
}
#[test]
fn renderable_item_owned_delegates() {
let boxed: Box<dyn Renderable> = Box::new("hello");
let item = RenderableItem::Owned(boxed);
assert_eq!(item.desired_height(80), 1);
}
#[test]
fn renderable_item_borrowed_delegates() {
let s = "hello";
let item = RenderableItem::Borrowed(&s as &dyn Renderable);
assert_eq!(item.desired_height(80), 1);
}
}
@@ -0,0 +1,52 @@
//! Bounds-checked buffer helpers.
//!
//! Ratatui's `Buffer::set_line`, `set_span`, and `set_string` panic when
//! given out-of-bounds coordinates (via `index_of`). During terminal resize
//! races, computed widget areas can momentarily exceed the buffer, causing
//! a crash.
//!
//! This extension trait provides `set_line_safe` / `set_span_safe` /
//! `set_string_safe` that silently skip the write when `y` is outside the
//! buffer — trading a single missed frame for a panic-free resize.
use ratatui::buffer::Buffer;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
/// Extension trait for bounds-checked buffer writes.
pub trait SafeBuf {
/// Like `Buffer::set_line` but returns immediately when `y` is outside
/// the buffer area.
fn set_line_safe(&mut self, x: u16, y: u16, line: &Line<'_>, width: u16);
/// Like `Buffer::set_span` but returns immediately when `y` is outside
/// the buffer area.
fn set_span_safe(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16);
/// Like `Buffer::set_string` but returns immediately when `y` is outside
/// the buffer area.
fn set_string_safe<S: AsRef<str>>(&mut self, x: u16, y: u16, string: S, style: Style);
}
impl SafeBuf for Buffer {
#[inline]
fn set_line_safe(&mut self, x: u16, y: u16, line: &Line<'_>, width: u16) {
if y >= self.area.y && y < self.area.bottom() && x < self.area.right() {
self.set_line(x, y, line, width);
}
}
#[inline]
fn set_span_safe(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16) {
if y >= self.area.y && y < self.area.bottom() && x < self.area.right() {
self.set_span(x, y, span, width);
}
}
#[inline]
fn set_string_safe<S: AsRef<str>>(&mut self, x: u16, y: u16, string: S, style: Style) {
if y >= self.area.y && y < self.area.bottom() && x < self.area.right() {
self.set_string(x, y, string, style);
}
}
}
@@ -0,0 +1,521 @@
//! Smooth scrollbar widget with follow-mode awareness.
//!
//! This module provides scrollbar rendering using `tui-scrollbar` for smooth
//! Unicode-based scrollbars with sub-character precision.
//!
//! # Visual Design
//!
//! The scrollbar visibility indicates follow mode state:
//! - **Following (at bottom):** Very dim scrollbar (subtle indicator of content above)
//! - **Not following:** Brighter scrollbar (draws attention to "scrolled up" state)
//!
//! This helps users understand when they're viewing live content vs. scrolled back.
//!
//! # Layout
//!
//! Callers should reserve space for the scrollbar:
//! - 1 column gap (visual separation from content)
//! - 1 column track (the scrollbar itself)
//!
//! Use [`split_area_for_scrollbar`] to compute content and scrollbar areas.
//!
//! # TODO: Mouse Support
//!
//! `tui-scrollbar` already provides mouse interaction support via:
//! - [`tui_scrollbar::ScrollBarInteraction`] for drag state
//! - [`tui_scrollbar::ScrollEvent`] / [`tui_scrollbar::PointerEvent`] for input
//! - [`tui_scrollbar::ScrollBar::handle_event`] for hit testing and drag math
//!
//! To wire this up:
//! 1. Store `ScrollBarInteraction` in pane state
//! 2. Translate crossterm `MouseEvent` to `tui_scrollbar::PointerEvent`
//! 3. Call `scrollbar.handle_event()` to get `ScrollCommand::SetOffset`
//! 4. Update scroll position accordingly
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui_core::buffer::Buffer as CoreBuffer;
use ratatui_core::layout::Rect as CoreRect;
use ratatui_core::widgets::Widget as _;
use tui_scrollbar::ScrollBar;
use tui_scrollbar::ScrollLengths;
use tui_scrollbar::{SUBCELL, ScrollMetrics};
/// When set, every scrollbar renders as a no-op. The pager toggles this on in
/// minimal (scrollback-native) mode, where lists/dropdowns show
/// no scrollbar bar at all — they scroll internally and the footer carries the
/// "↑/↓ navigate" hint. Off (default) everywhere else, so the full TUI is
/// unaffected.
static SCROLLBARS_HIDDEN: AtomicBool = AtomicBool::new(false);
/// Globally hide or show all scrollbars. See [`SCROLLBARS_HIDDEN`].
pub fn set_scrollbars_hidden(hidden: bool) {
SCROLLBARS_HIDDEN.store(hidden, Ordering::Relaxed);
}
/// Whether scrollbars are currently globally hidden.
pub fn scrollbars_hidden() -> bool {
SCROLLBARS_HIDDEN.load(Ordering::Relaxed)
}
/// Number of columns reserved between content and the scrollbar track.
/// This creates the "X" gap in the XSXBXX pattern (gap between selection_right and scrollbar).
const SCROLLBAR_GAP_COLS: u16 = 1;
/// Width of the scrollbar track itself (in terminal cells).
const SCROLLBAR_TRACK_COLS: u16 = 1;
/// Total columns reserved for scrollbar UI (gap + track).
pub const SCROLLBAR_TOTAL_COLS: u16 = SCROLLBAR_GAP_COLS + SCROLLBAR_TRACK_COLS;
/// Split an area into content + scrollbar regions.
///
/// Layout:
/// - `content_area`: original area minus [`SCROLLBAR_TOTAL_COLS`] on the right
/// - `scrollbar_area`: the last column of the original area (1 cell wide)
/// - The column between them is the "gap" (left intentionally blank)
///
/// Returns `(content_area, None)` when the terminal is too narrow.
///
/// **Note**: This always reserves space for scrollbar. Use [`maybe_split_for_scrollbar`]
/// to only reserve space when the scrollbar will actually be shown.
pub fn split_area_for_scrollbar(area: Rect) -> (Rect, Option<Rect>) {
if area.width <= SCROLLBAR_TOTAL_COLS {
return (area, None);
}
let content_width = area.width.saturating_sub(SCROLLBAR_TOTAL_COLS);
let content_area = Rect {
x: area.x,
y: area.y,
width: content_width,
height: area.height,
};
let scrollbar_area = Rect {
x: area.right().saturating_sub(1),
y: area.y,
width: SCROLLBAR_TRACK_COLS,
height: area.height,
};
(content_area, Some(scrollbar_area))
}
/// Split an area only if scrollbar is actually needed.
///
/// Unlike [`split_area_for_scrollbar`], this gives full width to content
/// when scrollbar won't be shown (`total_lines <= viewport_lines`).
///
/// Use this when you know the content height before splitting.
pub fn maybe_split_for_scrollbar(area: Rect, total_lines: u16) -> (Rect, Option<Rect>) {
// Only reserve space if scrollbar will actually be shown
if needs_scrollbar(total_lines, area.height) {
split_area_for_scrollbar(area)
} else {
// No scrollbar needed - give full width to content
(area, None)
}
}
/// Whether the scrollbar should be shown (content overflows viewport).
pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
total_lines > viewport_lines
}
/// Whether the view is at the bottom (following mode position).
#[allow(dead_code)] // Useful helper, kept for future use
pub fn is_at_bottom(total_lines: u16, viewport_lines: u16, offset: u16) -> bool {
let max_offset = total_lines.saturating_sub(viewport_lines);
offset >= max_offset
}
/// Result of mapping a scrollbar click/drag position to a scroll offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarClickResult {
/// Jump to the very top (click on first row of track).
Top,
/// Jump to the very bottom (click on last row of track).
Bottom,
/// Set scroll offset to this value (proportional position).
Offset(usize),
}
/// Map a click on the scrollbar gutter to a scroll offset.
///
/// Uses the same `tui_scrollbar::ScrollMetrics` that the renderer uses to
/// position the thumb, so the click is the exact inverse of the rendering.
/// Emulates `JumpToClick` behavior: centers the thumb on the click position.
///
/// # Arguments
///
/// * `cell_index` — 0-based row within the scrollbar area (screen_y - sb.y)
/// * `track_cells` — height of the scrollbar area (sb.height)
/// * `total_lines` — total content height (pre-scaled)
/// * `viewport_lines` — viewport height
///
/// Returns `Top`/`Bottom` for clicks on the first/last row, otherwise
/// an offset that places the thumb centered on the click.
pub fn scrollbar_click_to_offset(
cell_index: u16,
track_cells: u16,
total_lines: u16,
viewport_lines: u16,
) -> ScrollbarClickResult {
if track_cells == 0 {
return ScrollbarClickResult::Top;
}
// First row → go to top.
if cell_index == 0 {
return ScrollbarClickResult::Top;
}
// Last row → go to bottom.
if cell_index >= track_cells.saturating_sub(1) {
return ScrollbarClickResult::Bottom;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let metrics = ScrollMetrics::new(lengths, 0, track_cells);
// Center the thumb on the clicked cell (same as tui_scrollbar JumpToClick).
let position = (cell_index as usize)
.saturating_mul(SUBCELL)
.saturating_add(SUBCELL / 2);
let half_thumb = metrics.thumb_len() / 2;
let thumb_start = position.saturating_sub(half_thumb);
let offset = metrics.offset_for_thumb_start(thumb_start);
ScrollbarClickResult::Offset(offset)
}
/// Render a scrollbar with follow-mode aware styling.
///
/// # Arguments
///
/// * `buf` - The ratatui buffer to render into
/// * `scrollbar_area` - The 1-column area for the scrollbar track
/// * `total_lines` - Total content height in lines
/// * `viewport_lines` - Visible viewport height in lines
/// * `offset` - Current scroll offset (lines from top)
/// * `is_following` - Whether follow mode is active (dims the scrollbar)
///
/// The scrollbar is always rendered when content overflows, but styled differently
/// based on follow state:
/// - Following: very dim (subtle indicator)
/// - Not following: brighter (draws attention)
pub fn render_scrollbar(
buf: &mut Buffer,
scrollbar_area: Option<Rect>,
total_lines: u16,
viewport_lines: u16,
offset: u16,
is_following: bool,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with follow-aware styling
let (track_style, thumb_style) = scrollbar_styles(is_following);
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
}
}
/// Get track and thumb styles based on follow mode.
///
/// Following mode: very dim colors (scrollbar recedes into background)
/// Not following: brighter colors (scrollbar "pops out")
fn scrollbar_styles(is_following: bool) -> (Style, Style) {
let theme = crate::theme::Theme::current();
if is_following {
// Very dim - scrollbar is subtle when following
let track_style = Style::new().bg(theme.scrollbar_bg);
let thumb_style = Style::new().fg(theme.scrollbar_fg).bg(theme.scrollbar_bg);
(track_style, thumb_style)
} else {
// Brighter - scrollbar stands out when scrolled up
let track_style = Style::new().bg(theme.bg_highlight);
let thumb_style = Style::new().fg(theme.gray).bg(theme.bg_highlight);
(track_style, thumb_style)
}
}
/// Render a scrollbar with custom track and thumb styles.
///
/// Like [`render_scrollbar`] but allows custom styling for theme integration.
pub fn render_scrollbar_styled(
buf: &mut Buffer,
scrollbar_area: Option<Rect>,
total_lines: u16,
viewport_lines: u16,
offset: u16,
track_style: Style,
thumb_style: Style,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with custom styling
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn test_split_area_normal() {
let area = Rect::new(0, 0, 40, 10);
let (content, scrollbar) = split_area_for_scrollbar(area);
// Content should be 40 - 2 = 38 wide (gap + track)
assert_eq!(content.width, 38);
assert_eq!(content.height, 10);
// Scrollbar should be at x=39, 1 column wide
let sb = scrollbar.expect("scrollbar area");
assert_eq!(sb.x, 39);
assert_eq!(sb.width, 1);
assert_eq!(sb.height, 10);
}
#[test]
fn test_split_area_too_narrow() {
let area = Rect::new(0, 0, 2, 10);
let (content, scrollbar) = split_area_for_scrollbar(area);
// Too narrow - return original area, no scrollbar
assert_eq!(content, area);
assert!(scrollbar.is_none());
}
#[test]
fn test_maybe_split_reserves_when_needed() {
let area = Rect::new(0, 0, 40, 10);
// Content overflows (20 > 10) - should reserve scrollbar space
let (content, scrollbar) = maybe_split_for_scrollbar(area, 20);
assert_eq!(content.width, 38); // Reduced by 2 for gap + scrollbar track
assert!(scrollbar.is_some());
}
#[test]
fn test_maybe_split_full_width_when_not_needed() {
let area = Rect::new(0, 0, 40, 10);
// Content fits (5 <= 10) - should give full width to content
let (content, scrollbar) = maybe_split_for_scrollbar(area, 5);
assert_eq!(content.width, 40); // Full width
assert!(scrollbar.is_none());
}
#[test]
fn test_needs_scrollbar() {
assert!(needs_scrollbar(100, 10)); // Content > viewport
assert!(!needs_scrollbar(10, 10)); // Content == viewport
assert!(!needs_scrollbar(5, 10)); // Content < viewport
}
#[test]
fn test_is_at_bottom() {
// total=100, viewport=10 -> max_offset=90
assert!(is_at_bottom(100, 10, 90)); // At bottom
assert!(is_at_bottom(100, 10, 95)); // Past bottom (clamped)
assert!(!is_at_bottom(100, 10, 89)); // One line above bottom
assert!(!is_at_bottom(100, 10, 0)); // At top
}
#[test]
fn test_render_scrollbar_no_area() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
// Should not panic with None area
render_scrollbar(&mut buf, None, 100, 10, 0, false);
}
#[test]
fn test_render_scrollbar_no_overflow() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let mut buf = Buffer::empty(area);
// Content fits - should not render anything
render_scrollbar(&mut buf, scrollbar_area, 5, 10, 0, false);
// Check scrollbar column is empty (spaces with no custom background)
let sb = scrollbar_area.unwrap();
for y in 0..sb.height {
let cell = &buf[(sb.x, sb.y + y)];
assert_eq!(cell.symbol(), " ");
// The cell should NOT have our scrollbar background colors
// (i.e., it should be reset/default, not Color::Rgb)
if let Some(Color::Rgb(_, _, _)) = cell.style().bg {
panic!("Should not have RGB background when no scrollbar rendered");
}
// Otherwise - Reset, None, or other default-like value
}
}
#[test]
fn test_render_scrollbar_following_vs_not() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
// Render following
let mut buf_following = Buffer::empty(area);
render_scrollbar(&mut buf_following, scrollbar_area, 100, 10, 90, true);
// Render not following
let mut buf_not_following = Buffer::empty(area);
render_scrollbar(&mut buf_not_following, scrollbar_area, 100, 10, 50, false);
// The styles should differ - not following should be brighter
let sb = scrollbar_area.unwrap();
let following_style = buf_following[(sb.x, sb.y)].style();
let not_following_style = buf_not_following[(sb.x, sb.y)].style();
// Both should have backgrounds set (non-default)
assert!(following_style.bg.is_some());
assert!(not_following_style.bg.is_some());
// At 256-color or truecolor, the backgrounds should be distinguishable.
// At Basic (16-color) level, both dark grays map to Black — expected.
if crate::theme::color_support::get().has_256() {
assert_ne!(following_style.bg, not_following_style.bg);
}
}
#[test]
fn test_scrollbar_thumb_position() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let sb = scrollbar_area.unwrap();
// At top
let mut buf_top = Buffer::empty(area);
render_scrollbar(&mut buf_top, scrollbar_area, 100, 10, 0, false);
// At bottom
let mut buf_bottom = Buffer::empty(area);
render_scrollbar(&mut buf_bottom, scrollbar_area, 100, 10, 90, false);
// Count thumb cells (non-space)
let count_thumb = |buf: &Buffer| -> usize {
(0..sb.height)
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
.count()
};
// Both should have a thumb
let top_thumb = count_thumb(&buf_top);
let bottom_thumb = count_thumb(&buf_bottom);
assert!(top_thumb > 0, "Should have thumb at top");
assert!(bottom_thumb > 0, "Should have thumb at bottom");
// Thumb size should be consistent
assert_eq!(top_thumb, bottom_thumb, "Thumb size should be consistent");
// Thumb position should differ (visual inspection would show top vs bottom)
// We can check that the thumb cells are in different positions
let thumb_positions = |buf: &Buffer| -> Vec<u16> {
(0..sb.height)
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
.collect()
};
let top_pos = thumb_positions(&buf_top);
let bottom_pos = thumb_positions(&buf_bottom);
assert_ne!(
top_pos, bottom_pos,
"Thumb should be at different positions"
);
}
}
@@ -0,0 +1,540 @@
//! Native terminal rendering for command output.
//!
//! Bash/terminal tool output arrives as a raw PTY byte stream that can contain
//! ANSI SGR (colors/styles), cursor movement, line erases, and carriage returns
//! (progress bars rewriting a line). ratatui paints text verbatim and does not
//! interpret these, so without this module the scrollback shows literal escape
//! codes like `[1m[36m`.
//!
//! [`render_terminal_lines`] feeds the stream through a minimal, line-oriented
//! VTE emulator (built on the `vte` parser) and produces styled
//! [`Line`]s plus de-escaped plain text — what a terminal would actually
//! display. Unlike a screen/grid emulator it keeps an unbounded, fully-styled
//! transcript that maps onto the pager's line model.
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use vte::{Params, Parser, Perform};
use crate::theme::color_support::quantize;
/// Bound transcript growth against pathological cursor jumps. Tool output is
/// already truncated upstream; these only guard against escape-code abuse.
const MAX_ROWS: usize = 50_000;
const MAX_COLS: usize = 8_192;
/// A single rendered transcript line: styled spans plus de-escaped plain text.
pub struct RenderedLine {
pub line: Line<'static>,
pub plain: String,
}
/// Parse a raw terminal stream (ANSI SGR + cursor/erase + carriage return) into
/// styled lines. `base` is the default style for text without an SGR override.
///
/// Deterministic and idempotent: a fresh emulator per call, safe to invoke from
/// both the render path and the height-cache path.
pub fn render_terminal_lines(raw: &str, base: Style) -> Vec<RenderedLine> {
if raw.is_empty() {
return Vec::new();
}
let mut sink = TermSink::new(base);
let mut parser = Parser::new();
parser.advance(&mut sink, raw.as_bytes());
sink.finish()
}
/// De-escaped, cursor-resolved plain text of a terminal stream, for
/// clipboard/search. Lines are joined with `\n`.
pub fn render_terminal_plain(raw: &str) -> String {
render_terminal_lines(raw, Style::default())
.into_iter()
.map(|rl| rl.plain)
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Clone, Copy)]
struct Cell {
ch: char,
style: Style,
}
struct TermSink {
base: Style,
cur: Style,
rows: Vec<Vec<Cell>>,
row: usize,
col: usize,
}
impl TermSink {
fn new(base: Style) -> Self {
Self {
base,
cur: base,
rows: vec![Vec::new()],
row: 0,
col: 0,
}
}
fn ensure_row(&mut self) {
if self.row >= MAX_ROWS {
self.row = MAX_ROWS - 1;
}
while self.rows.len() <= self.row {
self.rows.push(Vec::new());
}
}
fn put(&mut self, ch: char) {
if self.col >= MAX_COLS {
return;
}
self.ensure_row();
let blank = Cell {
ch: ' ',
style: self.base,
};
let line = &mut self.rows[self.row];
if self.col >= line.len() {
line.resize(self.col + 1, blank);
}
line[self.col] = Cell {
ch,
style: self.cur,
};
self.col += 1;
}
fn newline(&mut self) {
self.row += 1;
self.col = 0;
self.ensure_row();
}
fn erase_line(&mut self, mode: u16) {
self.ensure_row();
let blank = Cell {
ch: ' ',
style: self.base,
};
let line = &mut self.rows[self.row];
match mode {
0 => line.truncate(self.col.min(line.len())),
1 => {
let end = (self.col + 1).min(line.len());
line[..end].fill(blank);
}
2 => line.clear(),
_ => {}
}
}
fn erase_display(&mut self, mode: u16) {
match mode {
0 => {
self.ensure_row();
let len = self.rows[self.row].len();
self.rows[self.row].truncate(self.col.min(len));
self.rows.truncate(self.row + 1);
}
2 | 3 => {
self.rows.clear();
self.rows.push(Vec::new());
self.row = 0;
self.col = 0;
}
_ => {}
}
}
fn apply_sgr(&mut self, params: &Params) {
if params.is_empty() {
self.cur = self.base;
return;
}
let groups: Vec<&[u16]> = params.iter().collect();
let mut i = 0;
while i < groups.len() {
let code = groups[i].first().copied().unwrap_or(0);
match code {
0 => self.cur = self.base,
1 => self.cur = self.cur.add_modifier(Modifier::BOLD),
2 => self.cur = self.cur.add_modifier(Modifier::DIM),
3 => self.cur = self.cur.add_modifier(Modifier::ITALIC),
4 => self.cur = self.cur.add_modifier(Modifier::UNDERLINED),
7 => self.cur = self.cur.add_modifier(Modifier::REVERSED),
22 => self.cur = self.cur.remove_modifier(Modifier::BOLD | Modifier::DIM),
23 => self.cur = self.cur.remove_modifier(Modifier::ITALIC),
24 => self.cur = self.cur.remove_modifier(Modifier::UNDERLINED),
27 => self.cur = self.cur.remove_modifier(Modifier::REVERSED),
30..=37 => self.cur.fg = Some(quantize(ansi16(code - 30))),
39 => self.cur.fg = self.base.fg,
40..=47 => self.cur.bg = Some(quantize(ansi16(code - 40))),
49 => self.cur.bg = self.base.bg,
90..=97 => self.cur.fg = Some(quantize(ansi16_bright(code - 90))),
100..=107 => self.cur.bg = Some(quantize(ansi16_bright(code - 100))),
38 => {
if let Some(c) = ext_color(&groups, &mut i) {
self.cur.fg = Some(quantize(c));
}
}
48 => {
if let Some(c) = ext_color(&groups, &mut i) {
self.cur.bg = Some(quantize(c));
}
}
_ => {}
}
i += 1;
}
}
fn finish(mut self) -> Vec<RenderedLine> {
// `str::lines()` ignores a single trailing newline; mirror that so a
// command ending in `\n` does not gain a spurious blank line.
if self.rows.last().is_some_and(|r| r.is_empty()) {
self.rows.pop();
}
let base = self.base;
self.rows
.into_iter()
.map(|cells| row_to_line(cells, base))
.collect()
}
}
impl Perform for TermSink {
fn print(&mut self, c: char) {
self.put(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\n' | 0x0b | 0x0c => self.newline(),
b'\r' => self.col = 0,
b'\t' => self.col = (self.col / 8 + 1) * 8,
0x08 => self.col = self.col.saturating_sub(1),
_ => {}
}
}
fn csi_dispatch(
&mut self,
params: &Params,
_intermediates: &[u8],
_ignore: bool,
action: char,
) {
match action {
'm' => self.apply_sgr(params),
'K' => self.erase_line(first_param(params, 0)),
'J' => self.erase_display(first_param(params, 0)),
'A' => self.row = self.row.saturating_sub(first_param(params, 1) as usize),
'B' => {
let n = first_param(params, 1) as usize;
self.row = (self.row + n).min(self.rows.len().saturating_sub(1));
}
'C' => self.col = (self.col + first_param(params, 1) as usize).min(MAX_COLS),
'D' => self.col = self.col.saturating_sub(first_param(params, 1) as usize),
'G' => {
self.col = (first_param(params, 1) as usize)
.saturating_sub(1)
.min(MAX_COLS)
}
_ => {}
}
}
}
/// First parameter value, substituting `default` for a missing or `0` value
/// (CSI cursor ops treat `0` as `1`; erase ops pass `0` as the default).
fn first_param(params: &Params, default: u16) -> u16 {
match params.iter().next().and_then(|p| p.first().copied()) {
Some(0) | None => default,
Some(v) => v,
}
}
/// Map a 0-7 ANSI color index to a named ratatui color.
fn ansi16(n: u16) -> Color {
match n {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
_ => Color::Gray,
}
}
/// Map a 0-7 bright ANSI color index to a named ratatui color.
fn ansi16_bright(n: u16) -> Color {
match n {
0 => Color::DarkGray,
1 => Color::LightRed,
2 => Color::LightGreen,
3 => Color::LightYellow,
4 => Color::LightBlue,
5 => Color::LightMagenta,
6 => Color::LightCyan,
_ => Color::White,
}
}
/// Resolve an extended color (`38`/`48`) in either `;` (advancing `i` over the
/// consumed groups) or `:` subparameter form. Returns an un-quantized color.
fn ext_color(groups: &[&[u16]], i: &mut usize) -> Option<Color> {
let g = groups[*i];
if g.len() >= 2 {
return parse_ext(&g[1..]);
}
match groups.get(*i + 1).and_then(|p| p.first().copied())? {
5 => {
let idx = groups.get(*i + 2).and_then(|p| p.first().copied())?;
*i += 2;
Some(Color::Indexed(idx as u8))
}
2 => {
let r = groups.get(*i + 2).and_then(|p| p.first().copied())?;
let g = groups.get(*i + 3).and_then(|p| p.first().copied())?;
let b = groups.get(*i + 4).and_then(|p| p.first().copied())?;
*i += 4;
Some(Color::Rgb(r as u8, g as u8, b as u8))
}
_ => None,
}
}
/// Parse the subparameter form of an extended color, e.g. `[5, n]` (256) or
/// `[2, r, g, b]` (with an optional leading colorspace id). Un-quantized.
fn parse_ext(sub: &[u16]) -> Option<Color> {
match sub.first().copied()? {
5 => sub.get(1).map(|n| Color::Indexed(*n as u8)),
2 => {
let vals = &sub[1..];
let (r, g, b) = match vals.len() {
3 => (vals[0], vals[1], vals[2]),
n if n >= 4 => (vals[n - 3], vals[n - 2], vals[n - 1]),
_ => return None,
};
Some(Color::Rgb(r as u8, g as u8, b as u8))
}
_ => None,
}
}
fn row_to_line(cells: Vec<Cell>, base: Style) -> RenderedLine {
let mut end = cells.len();
while end > 0 && cells[end - 1].ch == ' ' && cells[end - 1].style == base {
end -= 1;
}
let cells = &cells[..end];
if cells.is_empty() {
return RenderedLine {
line: Line::default(),
plain: String::new(),
};
}
let plain: String = cells.iter().map(|c| c.ch).collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut style = cells[0].style;
for c in cells {
if c.style != style {
spans.push(Span::styled(std::mem::take(&mut buf), style));
style = c.style;
}
buf.push(c.ch);
}
spans.push(Span::styled(buf, style));
RenderedLine {
line: Line::from(spans),
plain,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(raw: &str) -> String {
render_terminal_plain(raw)
}
fn lines(raw: &str) -> Vec<String> {
render_terminal_lines(raw, Style::default())
.into_iter()
.map(|rl| rl.plain)
.collect()
}
#[test]
fn strips_sgr_to_plain_text() {
assert_eq!(plain("\x1b[1m\x1b[36mbazel\x1b[0m"), "bazel");
}
#[test]
fn carriage_return_overwrites_in_place() {
assert_eq!(plain("aaaa\rbb"), "bbaa");
}
#[test]
fn progress_bar_collapses_to_final_state() {
assert_eq!(plain("10%\r50%\r100%\n"), "100%");
}
#[test]
fn newline_splits_lines() {
assert_eq!(lines("a\nb"), vec!["a", "b"]);
}
#[test]
fn trailing_newline_adds_no_blank_line() {
assert_eq!(lines("a\n"), vec!["a"]);
assert_eq!(lines("a\n\n"), vec!["a", ""]);
}
#[test]
fn cursor_up_then_carriage_return_and_erase() {
// Write two lines, move up, overwrite the start, erase to end of line.
assert_eq!(lines("line1\nline2\x1b[A\rXX\x1b[K"), vec!["XX", "line2"]);
}
#[test]
fn tab_advances_to_next_stop() {
assert_eq!(plain("a\tb"), "a b");
}
#[test]
fn malformed_escape_does_not_panic() {
assert_eq!(plain("\x1b[38;5mhi"), "hi");
assert!(plain("\x1b[99999999999m\x1b[mok").contains("ok"));
}
#[test]
fn sgr_splits_into_styled_spans() {
let rendered = render_terminal_lines("plain \x1b[31mred\x1b[0m", Style::default());
assert_eq!(rendered.len(), 1);
let spans = &rendered[0].line.spans;
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].content.as_ref(), "plain ");
assert_eq!(spans[1].content.as_ref(), "red");
assert!(spans[1].style.fg.is_some());
assert_eq!(rendered[0].plain, "plain red");
}
#[test]
fn idempotent_line_count() {
let raw = "a\nb\x1b[32mc\x1b[0m\rd\ne";
let first = render_terminal_lines(raw, Style::default()).len();
let second = render_terminal_lines(raw, Style::default()).len();
assert_eq!(first, second);
}
#[test]
fn empty_input_yields_no_lines() {
assert!(render_terminal_lines("", Style::default()).is_empty());
}
#[test]
fn ansi16_mapping() {
assert_eq!(ansi16(1), Color::Red);
assert_eq!(ansi16(6), Color::Cyan);
assert_eq!(ansi16_bright(2), Color::LightGreen);
assert_eq!(ansi16_bright(7), Color::White);
}
#[test]
fn ext_color_subparam_forms() {
assert_eq!(parse_ext(&[5, 42]), Some(Color::Indexed(42)));
assert_eq!(parse_ext(&[2, 10, 20, 30]), Some(Color::Rgb(10, 20, 30)));
assert_eq!(parse_ext(&[2, 0, 10, 20, 30]), Some(Color::Rgb(10, 20, 30)));
assert_eq!(parse_ext(&[2, 1]), None);
}
// Cross-platform robustness. Bash/terminal output is captured via pipes
// (non-TTY) on macOS, Linux, and Windows alike, so the input is plain text
// plus line endings plus optionally forced SGR — never a ConPTY screen
// stream. Windows uses CRLF, and unsupported control sequences (DEC private
// modes, OSC, cursor save/restore, absolute positioning) must be ignored
// without corrupting surrounding text.
#[test]
fn windows_crlf_line_endings() {
assert_eq!(lines("a\r\nb\r\nc\r\n"), vec!["a", "b", "c"]);
}
#[test]
fn ignores_dec_private_modes_and_osc() {
let raw = "\x1b[?25l\x1b]0;window title\x07hello\x1b[?1049h world\x1b[?25h";
assert_eq!(plain(raw), "hello world");
}
#[test]
fn ignores_cursor_save_restore_and_absolute_positioning() {
assert_eq!(plain("\x1b7\x1b[10;5Hkept\x1b8"), "kept");
}
#[test]
fn forced_sgr_over_crlf_renders_styled() {
let rendered =
render_terminal_lines("\x1b[01;31mmatch\x1b[0m\r\nplain\r\n", Style::default());
assert_eq!(rendered.len(), 2);
assert_eq!(rendered[0].plain, "match");
assert_eq!(rendered[1].plain, "plain");
assert!(rendered[0].line.spans.iter().any(|s| s.style.fg.is_some()));
}
// Real Windows shell output samples. Each pins a distinct parser behavior
// exercised by a sequence these shells actually emit on the wire.
// Git Bash / GNU `grep --color=always`: the match is wrapped in a bold-red
// SGR with an interleaved EL (`\x1b[K`) and closed by an empty-param reset
// (`\x1b[m`). The EL must not truncate already-printed text, and `\x1b[m`
// must restore the base style for the trailing run.
#[test]
fn git_bash_gnu_grep_color() {
let rendered =
render_terminal_lines("\x1b[01;31m\x1b[Kfoo\x1b[m\x1b[Kbar\n", Style::default());
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0].plain, "foobar");
let spans = &rendered[0].line.spans;
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].content.as_ref(), "foo");
assert!(spans[0].style.fg.is_some());
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(spans[1].content.as_ref(), "bar");
assert_eq!(spans[1].style, Style::default());
}
// PowerShell 7 (`$PSStyle`): 24-bit color via the semicolon form
// `\x1b[38;2;R;G;Bm`, which drives the multi-group extended-color branch of
// `ext_color` (consume-following-groups + advance). If that advance were
// wrong the trailing `0` param would reset and drop the color.
#[test]
fn powershell_truecolor_psstyle() {
let rendered = render_terminal_lines(
"\x1b[38;2;255;128;0mWARNING\x1b[0m: low disk\n",
Style::default(),
);
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0].plain, "WARNING: low disk");
let spans = &rendered[0].line.spans;
assert_eq!(spans[0].content.as_ref(), "WARNING");
assert!(spans[0].style.fg.is_some());
assert_eq!(spans[1].style, Style::default());
}
// Progress output (cargo/npm/pip style under cmd/PowerShell): a status line
// is wiped with EL mode 2 (`\x1b[2K`) regardless of cursor column, then
// rewritten, so the transcript collapses to the final line.
#[test]
fn progress_erase_entire_line_collapses() {
assert_eq!(lines("loading 99%\x1b[2K\rdone\n"), vec!["done"]);
}
}
@@ -0,0 +1,449 @@
//! Read/Edit tool-path resolution and surface formatting.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use unicode_width::UnicodeWidthStr;
use super::line_utils::truncate_str;
/// Read/Edit tool-header path paint surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolPathSurface {
/// Basename only.
Collapsed,
/// Relative to session cwd when lexically contained; else normalized.
Expanded,
/// Normalized target spelling for the modal preamble.
Fullscreen,
}
#[derive(Debug, Clone)]
struct ResolvedToolPath {
display_path: PathBuf,
relative_to_cwd: Option<String>,
}
fn expand_tilde_with_home(path: &Path, home: Option<&Path>) -> Option<PathBuf> {
use std::path::Component;
let mut components = path.components();
let Some(Component::Normal(first)) = components.next() else {
return Some(path.to_path_buf());
};
if first != "~" {
return Some(path.to_path_buf());
}
let mut expanded = home?.to_path_buf();
for component in components {
match component {
Component::Prefix(_) | Component::RootDir => {}
_ => expanded.push(component.as_os_str()),
}
}
Some(expanded)
}
/// Resolve the path the OS should receive, preserving `.`/`..` and symlink semantics.
pub(crate) fn resolve_tool_path_target_with_home(
path: &Path,
cwd: Option<&Path>,
home: Option<&Path>,
) -> Option<PathBuf> {
use std::path::Component;
let target = expand_tilde_with_home(path, home)?;
if target.is_absolute() || matches!(target.components().next(), Some(Component::Prefix(_))) {
return Some(target);
}
Some(match cwd {
Some(cwd) => cwd.join(target),
None => target,
})
}
fn non_empty_rel(rel: &Path) -> Option<String> {
let value = rel.to_string_lossy();
if value.is_empty() {
None
} else {
Some(value.into_owned())
}
}
fn home_dir() -> Option<&'static Path> {
static HOME: OnceLock<Option<PathBuf>> = OnceLock::new();
HOME.get_or_init(dirs::home_dir).as_deref()
}
/// Resolve the path-native target for OSC8 or background filesystem work.
pub fn resolve_tool_path_target(path: &str, cwd: Option<&Path>) -> Option<PathBuf> {
resolve_tool_path_target_with_home(Path::new(path), cwd, home_dir())
}
fn resolve_tool_path_with_home(
path: &str,
cwd: Option<&Path>,
home: Option<&Path>,
) -> ResolvedToolPath {
let target = resolve_tool_path_target_with_home(Path::new(path), cwd, home);
let display_path = target
.as_deref()
.map(kigi_paths::normalize_lexically)
.unwrap_or_else(|| PathBuf::from(path));
let relative_to_cwd = target.as_ref().and_then(|_| {
let cwd = kigi_paths::normalize_lexically(cwd?);
display_path.strip_prefix(cwd).ok().and_then(non_empty_rel)
});
ResolvedToolPath {
display_path,
relative_to_cwd,
}
}
fn resolve_tool_path(path: &str, cwd: Option<&Path>) -> ResolvedToolPath {
resolve_tool_path_with_home(path, cwd, home_dir())
}
fn path_for_fullscreen_header(path: &str, cwd: Option<&Path>) -> String {
resolve_tool_path(path, cwd)
.display_path
.to_string_lossy()
.into_owned()
}
fn path_for_expanded_header(path: &str, cwd: Option<&Path>) -> String {
let resolved = resolve_tool_path(path, cwd);
resolved
.relative_to_cwd
.unwrap_or_else(|| resolved.display_path.to_string_lossy().into_owned())
}
/// Shorten a file path to fit within `budget` display columns using fish-style
/// component shortening.
pub fn shorten_path(path: &str, budget: usize) -> String {
if budget == 0 {
return String::new();
}
if path.width() <= budget {
return path.to_string();
}
let parts: Vec<&str> = path.split('/').collect();
if parts.len() <= 1 {
return truncate_str(path, budget);
}
let mut shortened: Vec<String> = parts.iter().map(|part| part.to_string()).collect();
let last_idx = shortened.len() - 1;
for i in 0..last_idx {
if shortened.iter().map(String::len).sum::<usize>() + shortened.len() - 1 <= budget {
break;
}
if let Some(first) = parts[i].chars().next() {
shortened[i] = first.to_string();
}
}
let joined = shortened.join("/");
if joined.width() <= budget {
return joined;
}
let mut tail_start = 0;
for (i, _) in path.char_indices() {
if i == 0 {
continue;
}
if path.as_bytes().get(i.wrapping_sub(1)) == Some(&b'/') {
let candidate = format!("\u{2026}{}", &path[i - 1..]);
if candidate.width() <= budget {
tail_start = i - 1;
break;
}
}
}
if tail_start > 0 {
let result = format!("\u{2026}{}", &path[tail_start..]);
if result.width() <= budget {
return result;
}
}
truncate_str(path, budget)
}
pub fn path_basename(path: &str, budget: usize) -> String {
let name = path
.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
.unwrap_or(path);
truncate_str(name, budget)
}
/// Compatibility formatter: compact basename with `Some(width)`, else stored path.
pub fn path_for_tool_header(path: &str, width: Option<usize>, reserved: usize) -> String {
match width {
Some(width) => path_basename(path, width.saturating_sub(reserved)),
None => path.to_string(),
}
}
/// Path text for a Read/Edit tool-header surface.
pub fn path_for_tool_surface(
path: &str,
surface: ToolPathSurface,
cwd: Option<&Path>,
width: Option<usize>,
reserved: usize,
) -> String {
match surface {
ToolPathSurface::Collapsed => {
let budget = width.unwrap_or(usize::MAX).saturating_sub(reserved);
path_basename(path, budget)
}
ToolPathSurface::Expanded => path_for_expanded_header(path, cwd),
ToolPathSurface::Fullscreen => path_for_fullscreen_header(path, cwd),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shorten_path_fits() {
assert_eq!(shorten_path("src/main.rs", 20), "src/main.rs");
}
#[test]
fn shorten_path_fish_style() {
let result = shorten_path("crates/codegen/kigi-tui/src/views/foo.rs", 25);
assert!(result.width() <= 25, "got: {result}");
assert!(result.ends_with("foo.rs"), "got: {result}");
}
#[test]
fn shorten_path_front_truncate() {
let result = shorten_path(
"crates/codegen/kigi-tui/src/views/very_long_filename.rs",
20,
);
assert!(result.width() <= 20, "got: {result}");
}
#[test]
fn shorten_path_no_separator() {
assert_eq!(shorten_path("verylongfilename.rs", 10), "verylongf\u{2026}");
}
#[test]
fn shorten_path_zero_budget() {
assert_eq!(shorten_path("src/main.rs", 0), "");
}
#[test]
fn path_basename_handles_native_and_mixed_separators() {
assert_eq!(
path_basename("/Users/me/project/src/main.rs", 80),
"main.rs"
);
assert_eq!(path_basename("src/main.rs", 80), "main.rs");
assert_eq!(
path_basename(r"C:\Users\me/project/src/main.rs", 80),
"main.rs"
);
assert_eq!(path_basename(r"C:\Users\me\project\src\", 80), "src");
assert_eq!(path_basename("/Users/me/project/src/", 80), "src");
}
#[test]
fn path_basename_truncates_to_budget() {
assert_eq!(
path_basename("/x/verylongfilename.rs", 10),
"verylongf\u{2026}"
);
assert_eq!(path_basename("src/main.rs", 0), "");
}
#[test]
fn collapsed_surface_is_basename() {
assert_eq!(
path_for_tool_surface(
"/Users/me/project/src/main.rs",
ToolPathSurface::Collapsed,
None,
Some(80),
"Read ".len()
),
"main.rs"
);
}
#[test]
fn expanded_surface_normalizes_and_classifies_against_cwd() {
let cwd = Path::new("/Users/me/project");
assert_eq!(
path_for_tool_surface(
"/Users/me/project/src/main.rs",
ToolPathSurface::Expanded,
Some(cwd),
None,
0
),
"src/main.rs"
);
assert_eq!(
path_for_tool_surface(
"src/./nested/../main.rs",
ToolPathSurface::Expanded,
Some(cwd),
None,
0
),
"src/main.rs"
);
assert_eq!(
path_for_tool_surface(
"../outside.rs",
ToolPathSurface::Expanded,
Some(cwd),
None,
0
),
"/Users/me/outside.rs"
);
}
#[test]
fn filesystem_target_preserves_symlink_sensitive_parent_segments() {
let raw = Path::new("/repo/link/../target.rs");
assert_eq!(
resolve_tool_path_target_with_home(raw, None, Some(Path::new("/home/me"))),
Some(raw.to_path_buf())
);
}
#[cfg(unix)]
#[test]
fn tilde_expansion_uses_native_components_and_fails_closed_without_home() {
let home = Path::new("/home/me");
let cwd = Path::new("/repo");
assert_eq!(
resolve_tool_path_target_with_home(Path::new("~//foo.rs"), Some(cwd), Some(home)),
Some(home.join("foo.rs"))
);
assert_eq!(
resolve_tool_path_target_with_home(Path::new("~/dir/../foo.rs"), Some(cwd), Some(home)),
Some(home.join("dir/../foo.rs"))
);
assert_eq!(
resolve_tool_path_target_with_home(Path::new("~/foo.rs"), Some(cwd), None),
None
);
let unresolved = resolve_tool_path_with_home("~/foo.rs", Some(cwd), None);
assert_eq!(unresolved.display_path, PathBuf::from("~/foo.rs"));
assert_eq!(unresolved.relative_to_cwd, None);
}
#[test]
fn expanded_outside_cwd_stays_normalized_target() {
let cwd = Path::new("/Users/me/project");
let got =
path_for_tool_surface("/etc/hosts", ToolPathSurface::Expanded, Some(cwd), None, 0);
assert!(Path::new(&got).is_absolute(), "got {got}");
assert!(got.ends_with("hosts"), "got {got}");
assert!(!got.starts_with("/Users/me/project"), "got {got}");
}
#[test]
fn expanded_surface_uses_worktree_cwd() {
let cwd = Path::new("/Users/me/.kigi/worktrees/foo");
let path = "/Users/me/.kigi/worktrees/foo/crates/x/a.rs";
assert_eq!(
path_for_tool_surface(path, ToolPathSurface::Expanded, Some(cwd), None, 0),
"crates/x/a.rs"
);
}
#[test]
fn fullscreen_surface_uses_anchored_or_honestly_relative_target() {
let cwd = Path::new("/Users/me/project");
assert_eq!(
path_for_tool_surface(
"src/main.rs",
ToolPathSurface::Fullscreen,
Some(cwd),
None,
0
),
"/Users/me/project/src/main.rs"
);
let relative = resolve_tool_path("src/../main.rs", None);
assert_eq!(relative.display_path, PathBuf::from("main.rs"));
assert!(!relative.display_path.is_absolute());
}
#[test]
fn home_relative_target_preserves_filesystem_spelling_for_io() {
let Some(home) = dirs::home_dir() else {
return;
};
assert_eq!(resolve_tool_path_target("~", None), Some(home.clone()));
assert_eq!(
resolve_tool_path_target("~/project/../notes.md", None),
Some(home.join("project/../notes.md"))
);
assert_eq!(
resolve_tool_path("~/project/../notes.md", None).display_path,
kigi_paths::normalize_lexically(&home.join("notes.md"))
);
}
#[cfg(windows)]
#[test]
fn windows_tilde_and_drive_relative_targets_keep_native_semantics() {
let home = Path::new(r"C:\Users\me");
let cwd = Path::new(r"C:\repo");
assert_eq!(
resolve_tool_path_target_with_home(Path::new(r"~\foo.rs"), Some(cwd), Some(home)),
Some(home.join("foo.rs"))
);
assert_eq!(
resolve_tool_path_target_with_home(Path::new(r"C:foo.rs"), Some(cwd), Some(home)),
Some(PathBuf::from(r"C:foo.rs"))
);
let resolved = resolve_tool_path(r"C:foo.rs", Some(cwd));
assert_eq!(resolved.display_path, PathBuf::from(r"C:foo.rs"));
assert!(!resolved.display_path.is_absolute());
assert_eq!(
path_for_tool_surface(r"C:foo.rs", ToolPathSurface::Fullscreen, Some(cwd), None, 0),
r"C:foo.rs"
);
}
#[cfg(unix)]
#[test]
fn expanded_surface_does_not_dereference_symlink_aliases() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real_project");
std::fs::create_dir_all(real.join("src")).unwrap();
std::fs::write(real.join("src/main.rs"), b"fn main() {}").unwrap();
let link = dir.path().join("link_project");
std::os::unix::fs::symlink(&real, &link).unwrap();
let file_via_real = real.join("src/main.rs");
assert_eq!(
path_for_tool_surface(
file_via_real.to_str().unwrap(),
ToolPathSurface::Expanded,
Some(link.as_path()),
None,
0
),
file_via_real.to_string_lossy()
);
}
}
@@ -0,0 +1,148 @@
//! Video playback overlay chrome (border, title, progress bar).
//!
//! The video frame itself is rendered via post-flush escape sequences
//! by the caller, matching the image viewer pattern.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Widget};
use crate::prompt_images::VideoViewerState;
use crate::render::safe_buf::SafeBuf;
/// Render the video viewer popup chrome. Returns the popup `Rect`,
/// or `None` if the area is too small.
pub fn render_video_overlay(
buf: &mut Buffer,
area: Rect,
viewer: &VideoViewerState,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<Rect> {
if area.height < 8 || area.width < 20 {
return None;
}
crate::render::color::dim_area(buf, area, bg, 0.5);
// 90% centered popup.
let popup_width = ((area.width as u32 * 90) / 100)
.max(28)
.min(area.width as u32) as u16;
let popup_height = ((area.height as u32 * 90) / 100)
.max(8)
.min(area.height as u32) as u16;
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_rect = Rect::new(popup_x, popup_y, popup_width, popup_height);
ratatui::widgets::Clear.render(popup_rect, buf);
buf.set_style(popup_rect, Style::default().fg(text_fg).bg(bg));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_fg).bg(bg))
.style(Style::default().bg(bg))
.render(popup_rect, buf);
// Title centered in top border.
let title = match viewer.title {
Some(ref name) => format!(
" {} ({}\u{00d7}{}) ",
name, viewer.video_width, viewer.video_height
),
None => format!(
" Video ({}\u{00d7}{}) ",
viewer.video_width, viewer.video_height
),
};
let title_style = Style::default()
.fg(text_fg)
.bg(bg)
.add_modifier(Modifier::BOLD);
let tw = title.len() as u16;
let tx = popup_rect.x + (popup_rect.width.saturating_sub(tw)) / 2;
buf.set_span_safe(tx, popup_rect.y, &Span::styled(&title, title_style), tw);
// Progress bar on the bottom border row.
render_progress_bar(buf, popup_rect, viewer, text_fg, border_fg, bg);
Some(popup_rect)
}
/// Render the progress bar on the popup's bottom border row.
fn render_progress_bar(
buf: &mut Buffer,
popup_rect: Rect,
viewer: &VideoViewerState,
text_fg: Color,
bar_dim: Color,
bg: Color,
) {
let bar_y = popup_rect.y + popup_rect.height.saturating_sub(1);
let inner_width = popup_rect.width.saturating_sub(2) as usize;
if inner_width <= 10 {
return;
}
let icon = if viewer.playing {
"\u{25b6}"
} else {
"\u{23f8}"
};
let time_label = format!(
"{icon} {}/{} ",
format_time(viewer.position_secs()),
format_time(viewer.duration_secs),
);
let bar_width = inner_width.saturating_sub(time_label.len());
if bar_width <= 4 {
return;
}
let filled = ((viewer.progress() * bar_width as f64).round() as usize).min(bar_width);
let empty = bar_width.saturating_sub(filled);
let line = Line::from(vec![
Span::styled(time_label, Style::default().fg(text_fg).bg(bg)),
Span::styled(
"\u{2501}".repeat(filled),
Style::default().fg(text_fg).bg(bg),
),
Span::styled(
"\u{2500}".repeat(empty),
Style::default().fg(bar_dim).bg(bg),
),
]);
buf.set_line_safe(popup_rect.x + 1, bar_y, &line, inner_width as u16);
}
fn format_time(secs: f64) -> String {
let total = secs.round() as u64;
format!("{}:{:02}", total / 60, total % 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_time_zero() {
assert_eq!(format_time(0.0), "0:00");
}
#[test]
fn format_time_short() {
assert_eq!(format_time(5.4), "0:05");
}
#[test]
fn format_time_minutes() {
assert_eq!(format_time(90.0), "1:30");
}
}
File diff suppressed because it is too large Load Diff