Files
Kigi-CLI/crates/codegen/kigi-pager-render/src/render/line_utils.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

607 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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('…'));
// safe byte length
assert!(result.len() <= 12);
}
// 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()),
("", "", "", "")
);
}
}