use unicode_width::UnicodeWidthChar;
pub fn visible_width(s: &str) -> usize {
let mut width = 0;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
match chars.peek() {
| Some(&'[') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c.is_alphabetic() {
break;
}
}
continue;
},
| Some(&']') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c == '\x07' {
break;
}
if c == '\x1b' {
if let Some(&'\\') = chars.peek() {
chars.next();
break;
}
}
}
continue;
},
| _ => {},
}
}
width += ch.width().unwrap_or(0);
}
width
}
pub fn byte_index_at_visual_pos(s: &str, target_pos: usize) -> usize {
let mut width = 0;
let mut byte_idx = 0;
let mut chars = s.chars().peekable();
while let Some(&ch) = chars.peek() {
let ch_len = ch.len_utf8();
if ch == '\x1b' {
chars.next();
byte_idx += ch_len;
match chars.peek() {
| Some(&'[') => {
chars.next();
byte_idx += '['.len_utf8();
while let Some(&c) = chars.peek() {
chars.next();
byte_idx += c.len_utf8();
if c.is_alphabetic() {
break;
}
}
},
| Some(&']') => {
chars.next();
byte_idx += ']'.len_utf8();
while let Some(&c) = chars.peek() {
chars.next();
byte_idx += c.len_utf8();
if c == '\x07' {
break;
}
if c == '\x1b' {
if let Some(&'\\') = chars.peek() {
chars.next();
byte_idx += '\\'.len_utf8();
break;
}
}
}
},
| _ => {},
}
continue;
}
if width >= target_pos {
return byte_idx;
}
chars.next();
width += ch.width().unwrap_or(0);
byte_idx += ch_len;
if width >= target_pos {
return byte_idx;
}
}
byte_idx
}
pub fn truncate_to_width(s: &str, max_width: u16, ellipsis: &str) -> String {
let max = max_width as usize;
let ellip_width = visible_width(ellipsis);
let total = visible_width(s);
if total <= max {
return s.to_string();
}
let target = max.saturating_sub(ellip_width);
let mut result = String::new();
let mut w = 0;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
match chars.peek() {
| Some(&'[') => {
result.push(ch);
chars.next(); result.push('[');
while let Some(&c) = chars.peek() {
chars.next();
result.push(c);
if c.is_alphabetic() {
break;
}
}
continue;
},
| Some(&']') => {
result.push(ch);
chars.next(); result.push(']');
while let Some(&c) = chars.peek() {
chars.next();
result.push(c);
if c == '\x07' {
break;
}
if c == '\x1b' {
if let Some(&'\\') = chars.peek() {
chars.next();
result.push('\\');
break;
}
}
}
continue;
},
| _ => {},
}
}
let cw = ch.width().unwrap_or(0);
if w + cw > target {
break;
}
result.push(ch);
w += cw;
}
result.push_str(ellipsis);
if s.contains('\x1b') {
result.push_str("\x1b[0m");
}
result
}
#[derive(Debug, Clone, PartialEq)]
pub struct ActiveHyperlink {
pub params: String,
pub url: String,
pub terminator: String,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct AnsiCodeTracker {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub fg_color: Option<String>,
pub bg_color: Option<String>,
pub hyperlink: Option<ActiveHyperlink>,
}
impl AnsiCodeTracker {
pub fn new() -> Self {
Self::default()
}
fn parse_osc8(seq: &str) -> Option<Option<ActiveHyperlink>> {
let body = seq.strip_prefix("\x1b]")?;
let (body, terminator) = if body.ends_with("\x1b\\") {
(&body[..body.len() - 2], "\x1b\\".to_string())
} else if body.ends_with('\x07') {
(&body[..body.len() - 1], "\x07".to_string())
} else {
return None;
};
let rest = body.strip_prefix("8;")?;
let sep = rest.find(';')?;
let params = rest[..sep].to_string();
let url = rest[sep + 1..].to_string();
if url.is_empty() {
Some(None)
} else {
Some(Some(ActiveHyperlink {
params,
url,
terminator,
}))
}
}
pub fn process(&mut self, seq: &str) {
if let Some(parsed) = Self::parse_osc8(seq) {
self.hyperlink = parsed;
return;
}
let body = seq.strip_prefix("\x1b[").unwrap_or(seq);
let body = body.strip_suffix('m').unwrap_or(body);
for code in body.split(';') {
match code {
| "1" => self.bold = true,
| "3" => self.italic = true,
| "4" => self.underline = true,
| "22" => self.bold = false,
| "23" => self.italic = false,
| "24" => self.underline = false,
| "39" => self.fg_color = None,
| "49" => self.bg_color = None,
| c if c.starts_with('3') && c.len() >= 2 => self.fg_color = Some(c.to_string()),
| c if c.starts_with('4') && c.len() >= 2 => self.bg_color = Some(c.to_string()),
| _ => {},
}
}
}
pub fn current_codes(&self) -> String {
let mut parts = Vec::new();
if self.bold {
parts.push("1");
}
if self.italic {
parts.push("3");
}
if self.underline {
parts.push("4");
}
if let Some(ref fg) = self.fg_color {
parts.push(fg.as_str());
}
if let Some(ref bg) = self.bg_color {
parts.push(bg.as_str());
}
let mut result = if parts.is_empty() {
String::new()
} else {
format!("\x1b[{}m", parts.join(";"))
};
if let Some(ref link) = self.hyperlink {
result.push_str(&format!(
"\x1b]8;{};{}{}",
link.params, link.url, link.terminator
));
}
result
}
pub fn line_end_reset(&self) -> String {
let mut result = String::new();
if self.underline {
result.push_str("\x1b[24m");
}
if let Some(ref link) = self.hyperlink {
result.push_str(&format!("\x1b]8;;{}", link.terminator));
}
result
}
pub fn has_active_codes(&self) -> bool {
self.bold ||
self.italic ||
self.underline ||
self.fg_color.is_some() ||
self.bg_color.is_some() ||
self.hyperlink.is_some()
}
}
pub fn wrap_text_with_ansi(text: &str, width: u16) -> Vec<String> {
let w = width as usize;
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut current_width = 0;
let mut tracker = AnsiCodeTracker::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
match chars.peek() {
| Some(&'[') => {
chars.next();
let mut seq = String::from("\x1b[");
while let Some(&c) = chars.peek() {
seq.push(c);
chars.next();
if c.is_alphabetic() {
break;
}
}
tracker.process(&seq);
current.push_str(&seq);
continue;
},
| Some(&']') => {
chars.next();
let mut seq = String::from("\x1b]");
while let Some(&c) = chars.peek() {
seq.push(c);
chars.next();
if c == '\x07' {
break;
}
if c == '\x1b' {
if let Some(&'\\') = chars.peek() {
seq.push('\\');
chars.next();
break;
}
}
}
tracker.process(&seq);
current.push_str(&seq);
continue;
},
| _ => {},
}
}
if ch == '\n' {
if tracker.bold ||
tracker.italic ||
tracker.underline ||
tracker.fg_color.is_some() ||
tracker.bg_color.is_some()
{
current.push_str("\x1b[0m");
}
let reset = tracker.line_end_reset();
if !reset.is_empty() {
current.push_str(&reset);
}
lines.push(current);
current = tracker.current_codes();
current_width = 0;
continue;
}
let cw = ch.width().unwrap_or(0);
if current_width + cw > w && !current.is_empty() {
if tracker.bold ||
tracker.italic ||
tracker.underline ||
tracker.fg_color.is_some() ||
tracker.bg_color.is_some()
{
current.push_str("\x1b[0m");
}
let reset = tracker.line_end_reset();
if !reset.is_empty() {
current.push_str(&reset);
}
lines.push(current);
current = tracker.current_codes();
current_width = 0;
}
current.push(ch);
current_width += cw;
}
if !current.is_empty() {
lines.push(current);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracker_tracks_hyperlink() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]8;;https://example.com\x1b\\");
assert!(tracker.hyperlink.is_some());
assert_eq!(
tracker.hyperlink.as_ref().unwrap().url,
"https://example.com"
);
assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x1b\\");
}
#[test]
fn tracker_hyperlink_bel_terminator() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]8;;https://example.com\x07");
assert!(tracker.hyperlink.is_some());
assert_eq!(tracker.hyperlink.as_ref().unwrap().terminator, "\x07");
}
#[test]
fn tracker_hyperlink_close() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]8;;https://example.com\x1b\\");
assert!(tracker.hyperlink.is_some());
tracker.process("\x1b]8;;\x1b\\");
assert!(tracker.hyperlink.is_none());
}
#[test]
fn current_codes_includes_hyperlink() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]8;;https://example.com\x1b\\");
let codes = tracker.current_codes();
assert!(codes.contains("\x1b]8;;https://example.com\x1b\\"));
}
#[test]
fn line_end_reset_closes_hyperlink() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]8;;https://example.com\x1b\\");
let reset = tracker.line_end_reset();
assert!(reset.contains("\x1b]8;;\x1b\\"));
}
#[test]
fn wrap_preserves_hyperlink_across_lines() {
let text = "\x1b]8;;https://example.com\x1b\\hello world\x1b]8;;\x1b\\";
let lines = wrap_text_with_ansi(text, 6);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\x1b]8;;\x1b\\"));
assert!(lines[1].contains("\x1b]8;;https://example.com\x1b\\"));
}
#[test]
fn has_active_codes_with_hyperlink() {
let mut tracker = AnsiCodeTracker::new();
assert!(!tracker.has_active_codes());
tracker.process("\x1b]8;;https://example.com\x1b\\");
assert!(tracker.has_active_codes());
}
#[test]
fn line_end_reset_with_underline() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b[4m");
let reset = tracker.line_end_reset();
assert!(reset.contains("\x1b[24m"));
}
#[test]
fn wrap_hyperlink_bel_terminator() {
let text = "\x1b]8;;https://example.com\x07hello world\x1b]8;;\x07";
let lines = wrap_text_with_ansi(text, 6);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\x1b]8;;\x07"));
assert!(lines[1].contains("\x1b]8;;https://example.com\x07"));
}
#[test]
fn wrap_newline_with_active_sgr() {
let text = "\x1b[31mhello\nworld\x1b[0m";
let lines = wrap_text_with_ansi(text, 20);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\x1b[0m"));
assert!(lines[1].starts_with("\x1b[31m"));
}
#[test]
fn tracker_invalid_osc_ignored() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]8;;url");
assert!(tracker.hyperlink.is_none());
}
#[test]
fn tracker_invalid_osc_no_prefix() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b]9;;url\x1b\\");
assert!(tracker.hyperlink.is_none());
}
#[test]
fn has_active_codes_with_sgr() {
let mut tracker = AnsiCodeTracker::new();
tracker.process("\x1b[1m");
assert!(tracker.has_active_codes());
}
#[test]
fn truncate_jk_text_demo() {
let text = " j/k = navigate list Tab = switch focus i = insert mode Esc = normal mode q = quit";
let truncated = truncate_to_width(text, 80, "…");
let vw = visible_width(&truncated);
eprintln!("original vw: {}", visible_width(text));
eprintln!("truncated: {:?}", truncated);
eprintln!("truncated vw: {}", vw);
assert!(vw <= 80, "truncated width {} exceeds 80", vw);
assert!(truncated.ends_with("…"));
}
#[test]
fn truncate_to_width_preserves_ansi_prefix() {
let s = "\x1b[44mhello\x1b[0m";
let truncated = truncate_to_width(s, 3, "…");
assert!(truncated.starts_with("\x1b[44m"));
assert!(truncated.contains("…"));
assert!(truncated.ends_with("\x1b[0m"));
assert_eq!(visible_width(&truncated), 3);
}
#[test]
fn truncate_to_width_preserves_ansi_infix() {
let s = "hi\x1b[31mred\x1b[0mlo";
let truncated = truncate_to_width(s, 4, "…");
assert_eq!(visible_width(&truncated), 4);
assert!(truncated.contains("\x1b[31m"));
assert!(truncated.contains("\x1b[0m"));
}
#[test]
fn truncate_to_width_no_truncation_when_fits() {
let s = "\x1b[44mhi\x1b[0m";
let truncated = truncate_to_width(s, 5, "…");
assert_eq!(truncated, s);
}
#[test]
fn byte_index_at_visual_pos_plain() {
assert_eq!(byte_index_at_visual_pos("hello", 0), 0);
assert_eq!(byte_index_at_visual_pos("hello", 3), 3);
assert_eq!(byte_index_at_visual_pos("hello", 5), 5);
assert_eq!(byte_index_at_visual_pos("hello", 10), 5);
}
#[test]
fn byte_index_at_visual_pos_with_ansi_prefix() {
let s = "\x1b[31mhello\x1b[0m";
assert_eq!(byte_index_at_visual_pos(s, 0), 5);
assert_eq!(byte_index_at_visual_pos(s, 3), 8);
assert_eq!(byte_index_at_visual_pos(s, 5), 10);
assert_eq!(byte_index_at_visual_pos(s, 10), 14);
}
#[test]
fn byte_index_at_visual_pos_with_ansi_infix() {
let s = "hi\x1b[31mred\x1b[0mlo";
assert_eq!(byte_index_at_visual_pos(s, 0), 0);
assert_eq!(byte_index_at_visual_pos(s, 2), 2);
assert_eq!(byte_index_at_visual_pos(s, 3), 8);
assert_eq!(byte_index_at_visual_pos(s, 7), 16);
}
#[test]
fn byte_index_at_visual_pos_with_hyperlink() {
let s = "\x1b]8;;https://example.com\x07hello";
assert_eq!(byte_index_at_visual_pos(s, 0), 25);
assert_eq!(byte_index_at_visual_pos(s, 3), 28);
}
}