1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
//! ANSI escape code utilities and visible width calculations.
//!
//! Handles ANSI-aware string manipulation for terminal rendering.
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// Cursor position marker - APC sequence.
/// This is a zero-width escape sequence that terminals ignore.
pub const CURSOR_MARKER: &str = "\x1b_pi:c\x07";
/// Calculate the visible width of a string, ignoring ANSI escape sequences.
///
/// This is the number of terminal columns the string will occupy.
pub fn visible_width(s: &str) -> usize {
let stripped = strip_ansi(s);
UnicodeWidthStr::width(stripped.as_str())
}
/// Strip ANSI escape sequences from a string.
pub fn strip_ansi(s: &str) -> String {
// Simple ANSI stripping - removes escape sequences
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
// Cursor markers are private escape sequences, not ANSI CSI/OSC
// sequences. Treat the complete marker as zero-width; otherwise
// the old logic skipped only `\\x1b_` and counted `pi:c` as text,
// making the hardware cursor jump several columns when moving.
let mut marker_tail = chars.clone();
let marker = ['_', 'p', 'i', ':', 'c', '\x07'];
let is_cursor_marker = marker
.iter()
.all(|expected| marker_tail.next() == Some(*expected));
if is_cursor_marker {
chars = marker_tail;
continue;
}
// Start of escape sequence
match chars.peek() {
Some('[') => {
chars.next(); // consume '['
// CSI sequence: skip until final byte (0x40-0x7E)
while let Some(&c) = chars.peek() {
chars.next();
if (c as u8) >= 0x40 && (c as u8) <= 0x7E {
break;
}
}
}
Some(']') => {
chars.next(); // consume ']'
// OSC sequence: skip until BEL (0x07) or ST (ESC \)
while let Some(&c) = chars.peek() {
chars.next();
if c == '\x07' {
break;
}
if c == '\x1b' {
if let Some('\\') = chars.peek() {
chars.next();
break;
}
}
}
}
Some('(' | ')') => {
chars.next(); // consume '(' or ')'
// Character set designation: skip one more char
chars.next();
}
Some(_) => {
// Other escape sequences: skip next char
chars.next();
}
None => break,
}
} else {
result.push(c);
}
}
result
}
/// Slice a string by visible column positions.
///
/// Returns a substring that starts at `start_col` and has at most `width` visible columns.
/// Preserves ANSI escape sequences appropriately.
pub fn slice_by_column(s: &str, start_col: usize, width: usize, preserve_styles: bool) -> String {
if width == 0 {
return String::new();
}
let stripped = strip_ansi(s);
let segments: Vec<&str> = stripped.graphemes(true).collect();
// Find the character indices for the column range
let mut current_col = 0;
let mut start_idx = None;
let mut end_idx = segments.len();
let mut col_count = 0;
for (i, seg) in segments.iter().enumerate() {
let seg_width = UnicodeWidthStr::width(*seg);
if current_col >= start_col && start_idx.is_none() {
start_idx = Some(i);
}
if start_idx.is_some() {
col_count += seg_width;
if col_count >= width {
end_idx = i + 1;
break;
}
}
current_col += seg_width;
}
let start_idx = start_idx.unwrap_or(segments.len());
let result: String = segments[start_idx..end_idx].iter().copied().collect();
if preserve_styles && result != stripped {
// TODO: Preserve ANSI styles from the original string
// This is a simplified version
result
} else {
result
}
}
/// Normalize terminal output by resetting styles at line ends.
pub fn normalize_terminal_output(line: &str) -> String {
// Reset at end of line
if line.contains("\x1b[") && !line.ends_with("\x1b[0m") {
format!("{}\x1b[0m", line)
} else {
line.to_string()
}
}
/// Create a hyperlink using OSC 8.
pub fn hyperlink(url: &str, text: &str) -> String {
format!("\x1b]8;;{}\x07{}\x1b]8;;\x07", url, text)
}
/// Apply a foreground color using ANSI 256-color mode.
pub fn fg_256(color: u8, text: &str) -> String {
format!("\x1b[38;5;{}m{}\x1b[39m", color, text)
}
/// Apply a background color using ANSI 256-color mode.
pub fn bg_256(color: u8, text: &str) -> String {
format!("\x1b[48;5;{}m{}\x1b[49m", color, text)
}
/// Apply a foreground color using RGB.
pub fn fg_rgb(r: u8, g: u8, b: u8, text: &str) -> String {
format!("\x1b[38;2;{};{};{}m{}\x1b[39m", r, g, b, text)
}
/// Apply a background color using RGB.
pub fn bg_rgb(r: u8, g: u8, b: u8, text: &str) -> String {
format!("\x1b[48;2;{};{};{}m{}\x1b[49m", r, g, b, text)
}
/// Apply bold style.
pub fn bold(text: &str) -> String {
format!("\x1b[1m{}\x1b[22m", text)
}
/// Apply italic style.
pub fn italic(text: &str) -> String {
format!("\x1b[3m{}\x1b[23m", text)
}
/// Apply underline style.
pub fn underline(text: &str) -> String {
format!("\x1b[4m{}\x1b[24m", text)
}
/// Apply inverse (reverse video) style.
pub fn inverse(text: &str) -> String {
format!("\x1b[7m{}\x1b[27m", text)
}
/// Apply dim/faint style.
pub fn dim(text: &str) -> String {
format!("\x1b[2m{}\x1b[22m", text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_visible_width() {
assert_eq!(visible_width("hello"), 5);
assert_eq!(visible_width("\x1b[31mhello\x1b[0m"), 5);
assert_eq!(visible_width("你好"), 4); // Chinese characters are 2 columns each
assert_eq!(visible_width("\x1b[1m你好\x1b[0m"), 4);
}
#[test]
fn test_strip_ansi() {
assert_eq!(strip_ansi("hello"), "hello");
assert_eq!(strip_ansi("\x1b[31mhello\x1b[0m"), "hello");
assert_eq!(strip_ansi("\x1b[1;31mhello\x1b[0m"), "hello");
}
#[test]
fn test_hyperlink() {
let link = hyperlink("https://example.com", "Example");
assert!(link.contains("https://example.com"));
assert!(link.contains("Example"));
}
}