Skip to content

Commit 7034a09

Browse files
author
Yogthos
committed
fix(ui): strip ANSI escapes from terminal selection (copy/paste)
Markdown rendering bakes SGR codes (`\x1b[31m` …) directly into `LineEntry::text` for inline-styled spans (see markdown.rs:291). Without filtering, a user dragging to select styled text — bold, emphasis, code spans, diff highlights — copied the raw escape sequences into the clipboard. Paste lands in editors / shells as visible `[31m` garbage and the actual content fragmented around it. - New `ui::ansi::strip_ansi(&str) -> String` shares the CSI-skip loop with `wrap::visible_width` (terminator set 0x40..=0x7E so cursor-moves / scroll-region escapes also strip — anything a misbehaving producer might leave in the buffer). Lone ESC (not followed by `[`) also drops, defensive against truncated OSC/DCS heads. - `Renderer::selected_text` strips per-row before applying the column slice. Selection columns are user-perceived character offsets in the visible glyphs, so column-indexing the cleaned string is correct; the prior implementation column-indexed the escape-laden source, which could land mid-escape and produce mojibake on the substring path too. Tests: 7 new — 6 ANSI-strip cases (SGR sequences, nested escapes, lone ESC, unicode payload, non-SGR CSI, truncated escape) + 1 end-to-end through selected_text. 1071 → 1078 with plugin feature; 866 → 873 without.
1 parent 1a90d57 commit 7034a09

2 files changed

Lines changed: 137 additions & 10 deletions

File tree

src/ui/ansi.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,53 @@ pub fn strip_controls_compact(s: &str, policy: StripPolicy) -> CompactString {
100100
CompactString::from(out)
101101
}
102102

103+
/// Strip ANSI CSI escape sequences (`\x1b[…m` and friends) from `s`,
104+
/// returning a clean printable string. Used by clipboard / selection
105+
/// paths: the renderer bakes SGR codes into `LineEntry::text` for
106+
/// inline-styled markdown (see markdown.rs:291), and we don't want
107+
/// the user copying `\x1b[31mbold\x1b[0m` into their clipboard.
108+
///
109+
/// Mirrors the CSI-skip loop in `wrap::visible_width` (line 37) so
110+
/// the two stay consistent. Final-byte range matches the ECMA-48 CSI
111+
/// terminator set (0x40..=0x7E) so non-SGR sequences (cursor moves,
112+
/// scroll regions) also strip cleanly — anything a misbehaving
113+
/// producer might leave behind in the buffer.
114+
pub fn strip_ansi(s: &str) -> String {
115+
let bytes = s.as_bytes();
116+
let mut out = String::with_capacity(s.len());
117+
let mut i = 0;
118+
while i < bytes.len() {
119+
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
120+
// CSI introducer. Skip to (and past) the final byte in
121+
// the 0x40..=0x7E range.
122+
let mut j = i + 2;
123+
while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
124+
j += 1;
125+
}
126+
i = j.saturating_add(1).min(bytes.len());
127+
continue;
128+
}
129+
// Single ESC not followed by `[` — drop it alone to be
130+
// safe (could be an OSC/DCS start; better not to copy).
131+
if bytes[i] == 0x1b {
132+
i += 1;
133+
continue;
134+
}
135+
// UTF-8 step.
136+
let step = match bytes[i] {
137+
b if b < 0x80 => 1,
138+
b if b < 0xC0 => 1,
139+
b if b < 0xE0 => 2,
140+
b if b < 0xF0 => 3,
141+
_ => 4,
142+
};
143+
let end = (i + step).min(bytes.len());
144+
out.push_str(&s[i..end]);
145+
i = end;
146+
}
147+
out
148+
}
149+
103150
fn keep_char(c: char, policy: StripPolicy) -> bool {
104151
let cp = c as u32;
105152
if cp == 0x0A {
@@ -157,6 +204,49 @@ mod tests {
157204
}
158205
}
159206

207+
#[test]
208+
fn strip_ansi_removes_sgr_sequences_keeps_payload() {
209+
let s = "hello \x1b[31mred\x1b[0m world";
210+
assert_eq!(strip_ansi(s), "hello red world");
211+
}
212+
213+
#[test]
214+
fn strip_ansi_handles_consecutive_and_nested_escapes() {
215+
let s = "\x1b[1m\x1b[31mbold-red\x1b[0m\x1b[0m";
216+
assert_eq!(strip_ansi(s), "bold-red");
217+
}
218+
219+
#[test]
220+
fn strip_ansi_drops_lone_esc() {
221+
// ESC not followed by `[` — drop it on its own to keep the
222+
// clipboard payload safe (could be the head of an OSC/DCS).
223+
let s = "a\x1bb";
224+
assert_eq!(strip_ansi(s), "ab");
225+
}
226+
227+
#[test]
228+
fn strip_ansi_preserves_unicode_payload() {
229+
let s = "\x1b[32m日本語\x1b[0m 🚀";
230+
assert_eq!(strip_ansi(s), "日本語 🚀");
231+
}
232+
233+
#[test]
234+
fn strip_ansi_handles_non_sgr_csi() {
235+
// Cursor moves and scroll regions also end in a 0x40..=0x7E
236+
// final byte; the helper handles them too.
237+
let s = "before\x1b[2;5Hafter\x1b[Kend";
238+
assert_eq!(strip_ansi(s), "beforeafterend");
239+
}
240+
241+
#[test]
242+
fn strip_ansi_handles_truncated_escape() {
243+
// Trailing ESC with nothing after it (truncated stream).
244+
// Drop trailing bytes safely.
245+
let s = "abc\x1b[31";
246+
// No final byte → we consume to end of input.
247+
assert_eq!(strip_ansi(s), "abc");
248+
}
249+
160250
#[test]
161251
fn non_ascii_letters_pass_through() {
162252
let s = "naïve 日本語 🚀";

src/ui/renderer.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -307,33 +307,42 @@ impl Renderer {
307307
(Some(s), Some(e)) => (e, s),
308308
_ => return None,
309309
};
310+
// Markdown rendering bakes SGR escapes into `LineEntry::text`
311+
// (see markdown.rs:291 — inline emphasis / code spans embed
312+
// `\x1b[…m` directly in the line text). The selection
313+
// columns are user-perceived character offsets, NOT byte
314+
// offsets into the escape-laden source — slicing the raw
315+
// text would either land mid-escape or include the escape
316+
// in the clipboard. Strip per-row first, then index into
317+
// the cleaned form.
318+
let row_clean = |i: usize| -> Option<Vec<char>> {
319+
self.buffer
320+
.get(i)
321+
.map(|e| crate::ui::ansi::strip_ansi(&e.text).chars().collect())
322+
};
310323
let mut result = String::new();
311324
if start.0 == end.0 {
312-
// Single-row selection: substring from start.1 to end.1.
313-
if let Some(entry) = self.buffer.get(start.0) {
314-
let chars: Vec<char> = entry.text.chars().collect();
325+
if let Some(chars) = row_clean(start.0) {
315326
let lo = start.1.min(chars.len());
316327
let hi = end.1.min(chars.len());
317328
if lo < hi {
318329
result.extend(&chars[lo..hi]);
319330
}
320331
}
321332
} else {
322-
// Multi-row: tail of start row, full middle rows, head of end row.
323-
if let Some(entry) = self.buffer.get(start.0) {
324-
let chars: Vec<char> = entry.text.chars().collect();
333+
if let Some(chars) = row_clean(start.0) {
325334
let lo = start.1.min(chars.len());
326335
result.extend(&chars[lo..]);
327336
}
328337
for i in (start.0 + 1)..end.0 {
329338
result.push('\n');
330-
if let Some(entry) = self.buffer.get(i) {
331-
result.push_str(&entry.text);
339+
if let Some(chars) = row_clean(i) {
340+
let s: String = chars.into_iter().collect();
341+
result.push_str(&s);
332342
}
333343
}
334344
result.push('\n');
335-
if let Some(entry) = self.buffer.get(end.0) {
336-
let chars: Vec<char> = entry.text.chars().collect();
345+
if let Some(chars) = row_clean(end.0) {
337346
let hi = end.1.min(chars.len());
338347
result.extend(&chars[..hi]);
339348
}
@@ -1807,6 +1816,34 @@ mod tests {
18071816
assert_eq!(r.selected_text(), Some("café 🦀".to_string()));
18081817
}
18091818

1819+
/// Markdown rendering bakes SGR escapes into LineEntry::text;
1820+
/// the selection path must strip them before handing the
1821+
/// string to the clipboard. Columns reflect user-perceived
1822+
/// character offsets in the visible glyphs, not the
1823+
/// escape-laden source.
1824+
#[test]
1825+
fn selected_text_strips_ansi_escapes() {
1826+
// Visible text is "hello red world" (15 chars). The buffer
1827+
// line carries `\x1b[31m` around "red".
1828+
let mut r = fresh_with_text(&[]);
1829+
r.buffer.clear();
1830+
r.buffer.push(LineEntry {
1831+
text: CompactString::from("hello \x1b[31mred\x1b[0m world"),
1832+
color: Color::Reset,
1833+
});
1834+
r.selection_active = true;
1835+
// Select the full visible content (cols 0..15).
1836+
r.selection_start = Some((0, 0));
1837+
r.selection_end = Some((0, 15));
1838+
assert_eq!(r.selected_text(), Some("hello red world".to_string()));
1839+
1840+
// Substring selection lands on clean chars too —
1841+
// "red world" is cols 6..15 of the stripped text.
1842+
r.selection_end = Some((0, 15));
1843+
r.selection_start = Some((0, 6));
1844+
assert_eq!(r.selected_text(), Some("red world".to_string()));
1845+
}
1846+
18101847
/// `buffer_pos_at` clamps char_col to the line's length so dragging
18111848
/// past the right edge anchors at end-of-line rather than
18121849
/// silently extending past visible content.

0 commit comments

Comments
 (0)