use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(PartialEq)]
struct Atom<'a> {
text: &'a str,
width: usize,
}
fn atoms(line: &str) -> Option<Vec<Atom<'_>>> {
let mut result = Vec::new();
let mut offset = 0;
while offset < line.len() {
let tail = &line[offset..];
if tail.starts_with('\x1b') {
let bytes = tail.as_bytes();
let end = if tail.starts_with("\x1b[") {
let end = bytes
.iter()
.enumerate()
.skip(2)
.find(|(_, b)| (0x40..=0x7e).contains(*b))?
.0
+ 1;
if bytes[end - 1] != b'm' {
return None;
}
end
} else if tail.starts_with("\x1b]8;") {
let mut end = None;
for i in 4..bytes.len() {
if bytes[i] == 7 {
end = Some(i + 1);
break;
}
if bytes[i] == 27 && bytes.get(i + 1) == Some(&b'\\') {
end = Some(i + 2);
break;
}
}
end?
} else {
return None;
};
result.push(Atom {
text: &tail[..end],
width: 0,
});
offset += end;
} else {
let end = tail.find('\x1b').unwrap_or(tail.len());
for text in tail[..end].graphemes(true) {
if text.chars().any(char::is_control) {
return None;
}
result.push(Atom {
text,
width: UnicodeWidthStr::width(text),
});
}
offset += end;
}
}
Some(result)
}
fn context(atoms: &[Atom<'_>]) -> String {
let mut sgr = String::new();
let mut link = String::new();
for atom in atoms.iter().filter(|atom| atom.width == 0) {
if atom.text.starts_with("\x1b[") {
if matches!(atom.text, "\x1b[0m" | "\x1b[m") {
sgr.clear();
}
sgr.push_str(atom.text);
} else if atom.text.starts_with("\x1b]8;") {
link = atom.text.into();
}
}
format!("{sgr}{link}")
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SgrSlot {
Intensity,
Italic,
Underline,
Blink,
Inverse,
Conceal,
Strike,
Foreground,
Background,
Other(u16),
}
fn set_sgr_slot(slots: &mut Vec<(SgrSlot, String)>, slot: SgrSlot, sequence: String) {
if let Some((_, current)) = slots.iter_mut().find(|(existing, _)| *existing == slot) {
*current = sequence;
} else {
slots.push((slot, sequence));
}
}
fn remove_sgr_slot(slots: &mut Vec<(SgrSlot, String)>, slot: SgrSlot) {
slots.retain(|(existing, _)| *existing != slot);
}
fn update_sgr_context(slots: &mut Vec<(SgrSlot, String)>, sequence: &str) {
let Some(params) = sequence
.strip_prefix("\x1b[")
.and_then(|value| value.strip_suffix('m'))
else {
return;
};
if params.is_empty() {
slots.clear();
return;
}
let values: Vec<&str> = params.split(';').collect();
let mut index = 0;
while index < values.len() {
let Ok(code) = values[index].parse::<u16>() else {
index += 1;
continue;
};
let simple = || format!("\x1b[{code}m");
match code {
0 => slots.clear(),
1 | 2 => set_sgr_slot(slots, SgrSlot::Intensity, simple()),
3 => set_sgr_slot(slots, SgrSlot::Italic, simple()),
4 => set_sgr_slot(slots, SgrSlot::Underline, simple()),
5 | 6 => set_sgr_slot(slots, SgrSlot::Blink, simple()),
7 => set_sgr_slot(slots, SgrSlot::Inverse, simple()),
8 => set_sgr_slot(slots, SgrSlot::Conceal, simple()),
9 => set_sgr_slot(slots, SgrSlot::Strike, simple()),
22 => remove_sgr_slot(slots, SgrSlot::Intensity),
23 => remove_sgr_slot(slots, SgrSlot::Italic),
24 => remove_sgr_slot(slots, SgrSlot::Underline),
25 => remove_sgr_slot(slots, SgrSlot::Blink),
27 => remove_sgr_slot(slots, SgrSlot::Inverse),
28 => remove_sgr_slot(slots, SgrSlot::Conceal),
29 => remove_sgr_slot(slots, SgrSlot::Strike),
30..=37 | 90..=97 => {
set_sgr_slot(slots, SgrSlot::Foreground, simple());
}
40..=47 | 100..=107 => {
set_sgr_slot(slots, SgrSlot::Background, simple());
}
39 => remove_sgr_slot(slots, SgrSlot::Foreground),
49 => remove_sgr_slot(slots, SgrSlot::Background),
38 | 48 => {
let slot = if code == 38 {
SgrSlot::Foreground
} else {
SgrSlot::Background
};
let component_count = match values.get(index + 1).copied() {
Some("5") if values.get(index + 2).is_some() => 3,
Some("2") if values.get(index + 5).is_some() => 6,
_ => 0,
};
if component_count != 0 {
let sequence =
format!("\x1b[{}m", values[index..index + component_count].join(";"));
set_sgr_slot(slots, slot, sequence);
index += component_count;
continue;
}
set_sgr_slot(slots, SgrSlot::Other(code), simple());
}
_ => set_sgr_slot(slots, SgrSlot::Other(code), simple()),
}
index += 1;
}
}
fn update_osc8_context(link: &mut Option<String>, sequence: &str) {
let Some(content) = sequence.strip_prefix("\x1b]8;").and_then(|value| {
value
.strip_suffix('\x07')
.or_else(|| value.strip_suffix("\x1b\\"))
}) else {
return;
};
let Some((_, uri)) = content.split_once(';') else {
return;
};
if uri.is_empty() {
*link = None;
} else {
*link = Some(sequence.to_string());
}
}
fn wrapped_context(atoms: &[Atom<'_>]) -> String {
let mut slots = Vec::new();
let mut link = None;
for atom in atoms.iter().filter(|atom| atom.width == 0) {
if atom.text.starts_with("\x1b[") {
update_sgr_context(&mut slots, atom.text);
} else if atom.text.starts_with("\x1b]8;") {
update_osc8_context(&mut link, atom.text);
}
}
let mut output = slots
.into_iter()
.map(|(_, sequence)| sequence)
.collect::<String>();
if let Some(link) = link {
output.push_str(&link);
}
output
}
pub(crate) fn isolate_wrapped_lines(lines: &mut [String]) {
let mut carry = String::new();
for line in lines {
let source = format!("{carry}{line}");
let Some(parsed) = atoms(&source) else {
carry.clear();
continue;
};
carry = wrapped_context(&parsed);
*line = format!("{source}\x1b[0m\x1b]8;;\x07");
}
}
pub(crate) fn slice(line: &str, start: usize, width: usize) -> Option<String> {
let atoms = atoms(line)?;
let end = start.saturating_add(width);
let mut column = 0usize;
let mut started = false;
let mut output = String::new();
for (index, atom) in atoms.iter().enumerate() {
if atom.width == 0 {
if started {
output.push_str(atom.text);
}
continue;
}
let next = column + atom.width;
if column >= end {
break;
}
if next > start {
if !started {
output.push_str(&context(&atoms[..index]));
started = true;
}
if column >= start && next <= end {
output.push_str(atom.text);
} else {
output.push_str(&" ".repeat(next.min(end).saturating_sub(column.max(start))));
}
}
column = next;
}
Some(output)
}
pub(crate) fn patch(old: &str, new: &str) -> Option<(usize, String)> {
let old = atoms(old)?;
let new = atoms(new)?;
let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
let column = old[..prefix].iter().map(|a| a.width).sum();
let old_width: usize = old.iter().map(|a| a.width).sum();
let new_width: usize = new.iter().map(|a| a.width).sum();
let mut suffix = 0;
if old_width == new_width {
suffix = old[prefix..]
.iter()
.rev()
.zip(new[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
while suffix > 0
&& context(&old[..old.len() - suffix]) != context(&new[..new.len() - suffix])
{
if new[new.len() - suffix].width == 0 {
suffix -= 1;
} else {
suffix = 0;
}
}
}
let mut text = context(&new[..prefix]);
for atom in &new[prefix..new.len() - suffix] {
text.push_str(atom.text);
}
text.push_str(&" ".repeat(old_width.saturating_sub(new_width)));
text.push_str("\x1b[0m\x1b]8;;\x07");
Some((column, text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrapped_style_context_keeps_only_active_sgr_state() {
let mut lines: Vec<String> = (0..20).map(|_| "\x1b[31mA\x1b[39m".to_string()).collect();
isolate_wrapped_lines(&mut lines);
let lengths: Vec<usize> = lines.iter().map(String::len).collect();
assert!(lengths.iter().all(|length| *length < 64));
assert!(lengths.windows(2).all(|pair| pair[0] == pair[1]));
let mut active_style = vec!["\x1b[31mred".to_string(), "tail".to_string()];
isolate_wrapped_lines(&mut active_style);
assert!(active_style[1].starts_with("\x1b[31mtail"));
}
#[test]
fn scrolling_chat_does_not_write_unchanged_sidebar() {
let (column, text) = patch(
"chat old │ \x1b[2mRUN STATS\x1b[22m",
"chat new │ \x1b[2mRUN STATS\x1b[22m",
)
.unwrap();
assert_eq!(column, 5);
assert!(text.starts_with("new"));
assert!(!text.contains("RUN STATS"));
let (_, text) = patch(
"\x1b[31mold\x1b[0m │ \x1b[2mRUN STATS\x1b[22m",
"\x1b[32mnew\x1b[0m │ \x1b[2mRUN STATS\x1b[22m",
)
.unwrap();
assert!(!text.contains("RUN STATS"));
assert!(text.contains("\x1b[32mnew"));
let (column, text) = patch("chat same │ TPS 123", "chat same │ TPS 456").unwrap();
assert_eq!(column, 16);
assert!(!text.contains("chat same"));
}
#[test]
fn graphemes_styles_and_shrinking_lines_are_preserved() {
let (column, text) = patch("中文 👩💻 old", "中文 👩💻 new").unwrap();
assert_eq!(column, 8);
assert!(text.starts_with("new"));
let (_, text) = patch("\x1b[31mred\x1b[0m", "\x1b[32mred\x1b[0m").unwrap();
assert!(text.contains("\x1b[32mred"));
let (column, text) = patch("long text", "long").unwrap();
assert_eq!(column, 4);
assert!(text.starts_with(" "));
assert!(patch("plain", "\x1b[2Junsafe").is_none());
}
}