use std::sync::atomic::{AtomicBool, Ordering};
const OSC8_PREFIX: &str = "\x1b]8;;";
const OSC8_TERMINATOR: &str = "\x1b\\";
const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinkRegion {
pub row: u16,
pub col_start: u16,
pub col_end: u16,
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineLink {
pub col_start: usize,
pub col_end: usize,
pub target: String,
}
impl LineLink {
#[must_use]
pub fn shifted(&self, columns: usize) -> Self {
Self {
col_start: self.col_start.saturating_add(columns),
col_end: self.col_end.saturating_add(columns),
target: self.target.clone(),
}
}
}
#[must_use]
pub fn link_regions_for_lines(
area: ratatui::layout::Rect,
links: &[Vec<LineLink>],
) -> Vec<LinkRegion> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let width = usize::from(area.width);
let mut regions = Vec::new();
for (line_index, line_links) in links.iter().take(usize::from(area.height)).enumerate() {
let row = area
.y
.saturating_add(u16::try_from(line_index).unwrap_or(u16::MAX));
for link in line_links {
if link.col_start >= width || link.col_end < link.col_start {
continue;
}
let start = link.col_start;
let end = link.col_end.min(width.saturating_sub(1));
regions.push(LinkRegion {
row,
col_start: area
.x
.saturating_add(u16::try_from(start).unwrap_or(u16::MAX)),
col_end: area
.x
.saturating_add(u16::try_from(end).unwrap_or(u16::MAX)),
target: link.target.clone(),
});
}
}
regions
}
pub fn write_osc8_open(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> {
w.write_all(OSC8_PREFIX.as_bytes())?;
write_sanitized_target(w, target)?;
w.write_all(OSC8_TERMINATOR.as_bytes())
}
fn write_sanitized_target(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = [0u8; 4];
for ch in target.chars() {
let value = ch.encode_utf8(&mut encoded);
if ch.is_control() {
for &byte in value.as_bytes() {
w.write_all(&[
b'%',
HEX[usize::from(byte >> 4)],
HEX[usize::from(byte & 0x0f)],
])?;
}
} else {
w.write_all(value.as_bytes())?;
}
}
Ok(())
}
pub fn write_osc8_close(w: &mut impl std::io::Write) -> std::io::Result<()> {
w.write_all(OSC8_CLOSE.as_bytes())
}
static ENABLED: AtomicBool = AtomicBool::new(true);
pub fn set_enabled(enabled: bool) {
ENABLED.store(enabled, Ordering::Relaxed);
}
#[must_use]
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
use std::cell::RefCell;
thread_local! {
pub static FRAME_LINKS: RefCell<Vec<LinkRegion>> = const { RefCell::new(Vec::new()) };
}
pub fn set_frame_links(links: Vec<LinkRegion>) {
FRAME_LINKS.with(|cell| {
*cell.borrow_mut() = links;
});
}
pub fn append_frame_links(links: Vec<LinkRegion>) {
FRAME_LINKS.with(|cell| cell.borrow_mut().extend(links));
}
pub fn overlay_frame_links(area: ratatui::layout::Rect, links: Vec<LinkRegion>) {
if area.width == 0 || area.height == 0 {
append_frame_links(links);
return;
}
let x_start = area.x;
let x_end = area.right();
let y_start = area.y;
let y_end = area.bottom();
FRAME_LINKS.with(|cell| {
let mut current = cell.borrow_mut();
let mut visible = Vec::with_capacity(current.len().saturating_add(links.len()));
for region in current.drain(..) {
if region.row < y_start
|| region.row >= y_end
|| region.col_end < x_start
|| region.col_start >= x_end
{
visible.push(region);
continue;
}
if region.col_start < x_start {
let mut left = region.clone();
left.col_end = x_start.saturating_sub(1);
visible.push(left);
}
if region.col_end >= x_end {
let mut right = region;
right.col_start = x_end;
visible.push(right);
}
}
visible.extend(links);
*current = visible;
});
}
pub fn take_frame_links() -> Vec<LinkRegion> {
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
pub fn strip_ansi_into(s: &str, out: &mut String) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() {
let next = bytes[i + 1];
match next {
b'[' => {
let mut j = i + 2;
while j < bytes.len() {
let b = bytes[j];
if (0x40..=0x7e).contains(&b) {
j += 1;
break;
}
j += 1;
}
i = j;
continue;
}
b']' | b'P' | b'X' | b'^' | b'_' => {
let mut j = i + 2;
while j < bytes.len() {
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
_ => {
i += 2;
continue;
}
}
}
let b = bytes[i];
if b < 0x80 {
if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' {
i += 1;
continue;
}
out.push(b as char);
i += 1;
} else {
let len = utf8_seq_len(b);
let end = (i + len).min(bytes.len());
if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
out.push_str(chunk);
}
i = end;
}
}
}
fn utf8_seq_len(lead: u8) -> usize {
if lead < 0xc0 {
1
} else if lead < 0xe0 {
2
} else if lead < 0xf0 {
3
} else {
4
}
}
pub fn strip_into(s: &str, out: &mut String) {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 4 <= bytes.len()
&& bytes[i] == 0x1b
&& bytes[i + 1] == b']'
&& bytes[i + 2] == b'8'
&& bytes[i + 3] == b';'
{
let mut j = i + 4;
while j < bytes.len() {
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' {
j += 2;
break;
}
j += 1;
}
i = j;
continue;
}
let b = bytes[i];
if b < 0x80 {
out.push(b as char);
i += 1;
} else {
let len = utf8_seq_len(b);
let end = (i + len).min(bytes.len());
if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) {
out.push_str(chunk);
}
i = end;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static FLAG_GUARD: Mutex<()> = Mutex::new(());
fn strip(s: &str) -> String {
let mut out = String::with_capacity(s.len());
strip_into(s, &mut out);
out
}
fn wrapped_link(target: &str, label: &str) -> String {
format!("{OSC8_PREFIX}{target}{OSC8_TERMINATOR}{label}{OSC8_CLOSE}")
}
#[test]
fn wrapped_link_fixture_is_osc_8_compliant() {
let wrapped = wrapped_link("https://example.com", "click me");
assert_eq!(
wrapped,
"\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\"
);
}
#[test]
fn strip_removes_wrapper_keeps_label() {
let wrapped = wrapped_link("https://example.com", "click me");
assert_eq!(strip(&wrapped), "click me");
}
#[test]
fn strip_handles_bel_terminator() {
let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07";
assert_eq!(strip(wrapped), "click me");
}
#[test]
fn strip_passes_through_text_with_no_escapes() {
let plain = "no escapes here";
assert_eq!(strip(plain), plain);
}
#[test]
fn strip_preserves_non_osc_8_escapes() {
let mixed = format!(
"\x1b[31mred\x1b[0m {wrapped}",
wrapped = wrapped_link("https://example.com", "click")
);
assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click");
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
strip_ansi_into(s, &mut out);
out
}
#[test]
fn strip_ansi_removes_csi_sgr_and_keeps_text() {
let coloured = "526 \x1b[1;32mOPEN\x1b[0m bug fix";
assert_eq!(strip_ansi(coloured), "526 OPEN bug fix");
}
#[test]
fn strip_ansi_removes_osc_8_wrapper() {
let wrapped = wrapped_link("https://example.com", "click");
assert_eq!(strip_ansi(&wrapped), "click");
}
#[test]
fn strip_ansi_preserves_newlines_tabs_and_cr() {
let s = "a\nb\tc\rd";
assert_eq!(strip_ansi(s), "a\nb\tc\rd");
}
#[test]
fn strip_ansi_drops_lone_control_bytes() {
let s = "a\x07b\x01c";
assert_eq!(strip_ansi(s), "abc");
}
#[test]
fn strip_ansi_preserves_utf8_multibyte_chars() {
let s = "Phase 1: ç¬¬ä¸€æ¥ README é 🚀";
assert_eq!(strip_ansi(s), "Phase 1: ç¬¬ä¸€æ¥ README é 🚀");
let coloured = "\x1b[1;32m第一æ¥\x1b[0m done";
assert_eq!(strip_ansi(coloured), "ç¬¬ä¸€æ¥ done");
}
#[test]
fn strip_preserves_utf8_multibyte_chars() {
let wrapped = wrapped_link("https://example.com", "点击我");
assert_eq!(strip(&wrapped), "点击我");
}
#[test]
fn open_sequence_percent_encodes_target_control_injection() {
let target = "https://safe.test/a\x07b\x1b]8;;https://evil.test\x1b\\c\x7f\u{009c}";
let mut bytes = Vec::new();
write_osc8_open(&mut bytes, target).expect("write OSC 8 open");
let rendered = String::from_utf8(bytes.clone()).expect("valid UTF-8 output");
assert_eq!(rendered.matches(OSC8_PREFIX).count(), 1, "{rendered:?}");
assert_eq!(rendered.matches(OSC8_TERMINATOR).count(), 1, "{rendered:?}");
assert_eq!(bytes.iter().filter(|byte| **byte == 0x1b).count(), 2);
assert!(!bytes.contains(&0x07), "BEL escaped: {rendered:?}");
assert!(!bytes.contains(&0x7f), "DEL escaped: {rendered:?}");
assert!(
rendered.contains("a%07b%1B]8;;https://evil.test%1B\\c%7F%C2%9C"),
"control bytes must be percent-encoded: {rendered:?}"
);
}
#[test]
fn enabled_is_true_by_default_when_untouched() {
let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
assert!(enabled());
}
#[test]
fn set_enabled_round_trips() {
let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let prior = enabled();
set_enabled(false);
assert!(!enabled());
set_enabled(true);
assert!(enabled());
set_enabled(prior);
}
#[test]
fn line_links_translate_to_absolute_clipped_regions() {
let area = ratatui::layout::Rect::new(7, 3, 8, 2);
let links = vec![
vec![
LineLink {
col_start: 2,
col_end: 20,
target: "https://example.test/long".to_string(),
},
LineLink {
col_start: 8,
col_end: 9,
target: "outside".to_string(),
},
],
vec![LineLink {
col_start: 0,
col_end: 1,
target: "https://example.test/next".to_string(),
}],
vec![LineLink {
col_start: 0,
col_end: 0,
target: "below viewport".to_string(),
}],
];
assert_eq!(
link_regions_for_lines(area, &links),
vec![
LinkRegion {
row: 3,
col_start: 9,
col_end: 14,
target: "https://example.test/long".to_string(),
},
LinkRegion {
row: 4,
col_start: 7,
col_end: 8,
target: "https://example.test/next".to_string(),
},
]
);
}
#[test]
fn opaque_overlay_replaces_and_clips_underlying_regions() {
set_frame_links(vec![
LinkRegion {
row: 4,
col_start: 0,
col_end: 20,
target: "under-wide".to_string(),
},
LinkRegion {
row: 5,
col_start: 6,
col_end: 8,
target: "under-covered".to_string(),
},
]);
overlay_frame_links(
ratatui::layout::Rect::new(5, 4, 10, 2),
vec![LinkRegion {
row: 4,
col_start: 7,
col_end: 8,
target: "modal".to_string(),
}],
);
assert_eq!(
take_frame_links(),
vec![
LinkRegion {
row: 4,
col_start: 0,
col_end: 4,
target: "under-wide".to_string(),
},
LinkRegion {
row: 4,
col_start: 15,
col_end: 20,
target: "under-wide".to_string(),
},
LinkRegion {
row: 4,
col_start: 7,
col_end: 8,
target: "modal".to_string(),
},
]
);
}
}