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:
@@ -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:
|
||||
/// - 0–15: standard/bright ANSI colors (uses common xterm defaults)
|
||||
/// - 16–231: 6×6×6 color cube
|
||||
/// - 232–255: 24-step grayscale ramp
|
||||
pub fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
|
||||
match index {
|
||||
// Standard colors (0–7) — 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 (8–15)
|
||||
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 (16–231)
|
||||
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 (232–255): 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 (16–255).
|
||||
///
|
||||
/// Searches both the 6×6×6 color cube (16–231) and the 24-step grayscale
|
||||
/// ramp (232–255), 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 (16–231) ---
|
||||
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 (232–255) ---
|
||||
// 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 (0–5) 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 0–15; 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: ®ex::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+200B–200F, U+202A–202E, U+2060–206F, 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
Reference in New Issue
Block a user