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)]
pub struct LinkRegion {
pub row: u16,
pub col_start: u16,
pub col_end: u16,
pub target: String,
}
pub fn write_osc8_open(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> {
w.write_all(OSC8_PREFIX.as_bytes())?;
w.write_all(target.as_bytes())?;
w.write_all(OSC8_TERMINATOR.as_bytes())
}
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 take_frame_links() -> Vec<LinkRegion> {
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
const OPEN_CELLS: [char; 4] = [']', '8', ';', ';'];
#[must_use]
pub fn extract_buffer_link_regions(buf: &mut Buffer, area: Rect) -> Vec<LinkRegion> {
let mut regions = Vec::new();
let x_start = area.x;
let x_end = area.x.saturating_add(area.width);
let y_start = area.y;
let y_end = area.y.saturating_add(area.height);
for y in y_start..y_end {
let mut x = x_start;
while x < x_end {
if matches_open(buf, x, y, x_end) {
let payload_start = x;
let mut scan = x + OPEN_CELLS.len() as u16;
let mut target = String::new();
let mut found_target_term = false;
while scan < x_end {
let ch = cell_char(buf, scan, y);
scan += 1;
if ch == '\\' {
found_target_term = true;
break;
}
target.push(ch);
}
if !found_target_term {
blank_cells(buf, payload_start..payload_start + 4, y);
x = scan;
continue;
}
let label_start = scan;
let mut found_close = false;
while scan + 4 < x_end {
if matches_open(buf, scan, y, x_end) && cell_char(buf, scan + 4, y) == '\\' {
found_close = true;
break;
}
scan += 1;
}
if !found_close {
blank_cells(buf, payload_start..scan, y);
x = scan;
continue;
}
let close_start = scan;
let close_end = scan + (OPEN_CELLS.len() as u16) + 1; if scan > label_start {
regions.push(LinkRegion {
row: y,
col_start: label_start,
col_end: scan - 1,
target,
});
}
blank_cells(buf, payload_start..label_start, y);
blank_cells(buf, close_start..close_end, y);
x = close_end;
continue;
}
x += 1;
}
}
regions
}
fn matches_open(buf: &Buffer, x: u16, y: u16, x_end: u16) -> bool {
if x.saturating_add(OPEN_CELLS.len() as u16) > x_end {
return false;
}
OPEN_CELLS
.iter()
.enumerate()
.all(|(i, want)| cell_char(buf, x + i as u16, y) == *want)
}
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
let sym = buf[(x, y)].symbol();
sym.chars().next().unwrap_or('\0')
}
fn blank_cells(buf: &mut Buffer, cols: std::ops::Range<u16>, y: u16) {
for x in cols {
if let Some(cell) = buf.cell_mut(ratatui::layout::Position { x, y }) {
cell.set_symbol(" ");
}
}
}
#[must_use]
pub fn wrap_link(target: &str, label: &str) -> String {
let mut out = String::with_capacity(target.len() + label.len() + 12);
out.push_str(OSC8_PREFIX);
out.push_str(target);
out.push_str(OSC8_TERMINATOR);
out.push_str(label);
out.push_str(OSC8_PREFIX);
out.push_str(OSC8_TERMINATOR);
out
}
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
}
#[test]
fn wrap_link_shape_is_osc_8_compliant() {
let wrapped = wrap_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 = wrap_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 = wrap_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 = wrap_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 = wrap_link("https://example.com", "点击我");
assert_eq!(strip(&wrapped), "点击我");
}
#[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);
}
fn render_lines(
lines: Vec<ratatui::text::Line<'static>>,
area: ratatui::layout::Rect,
) -> Buffer {
use ratatui::widgets::{Paragraph, Widget};
let mut buf = Buffer::empty(area);
Paragraph::new(lines).render(area, &mut buf);
buf
}
fn row_text(buf: &Buffer, y: u16, x_start: u16, x_end: u16) -> String {
(x_start..x_end)
.map(|x| buf[(x, y)].symbol().to_string())
.collect()
}
#[test]
fn extract_finds_label_span_target_and_blanks_payload() {
let target = "https://x.test";
let label = "click";
let wrapped = wrap_link(target, label);
let area = ratatui::layout::Rect::new(0, 0, 40, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
wrapped,
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 1, "exactly one link region");
let r = ®ions[0];
assert_eq!(r.row, 0);
assert_eq!(r.target, target);
let expected_start = 4 + target.len() as u16 + 1;
let expected_end = expected_start + label.len() as u16 - 1;
assert_eq!(r.col_start, expected_start);
assert_eq!(r.col_end, expected_end);
assert_eq!(
row_text(&buf, 0, expected_start, expected_start + label.len() as u16),
label
);
let full = row_text(&buf, 0, 0, expected_end + 6);
assert!(
!full.contains(']') && !full.contains('\\') && !full.contains('h'),
"payload bytes blanked, got: {full:?}"
);
}
#[test]
fn extract_handles_two_links_same_row() {
let w1 = wrap_link("https://a.test", "AAA");
let w2 = wrap_link("https://b.test", "BB");
let combined = format!("{w1} {w2}");
let area = ratatui::layout::Rect::new(0, 0, 60, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
combined,
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 2, "two disjoint links");
assert_eq!(regions[0].target, "https://a.test");
assert_eq!(regions[1].target, "https://b.test");
let a_span = regions[0].col_start..=regions[0].col_end;
let b_span = regions[1].col_start..=regions[1].col_end;
assert!(a_span.end() < b_span.start(), "regions must not overlap");
let full = row_text(&buf, 0, 0, 60);
assert!(!full.contains(']'), "no open/close brackets remain");
assert!(!full.contains('\\'), "no terminator backslash remains");
}
#[test]
fn extract_uses_absolute_coordinates_with_area_offset() {
let wrapped = wrap_link("u", "L");
let area = ratatui::layout::Rect::new(5, 3, 30, 2);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
wrapped,
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].row, 3, "row includes area.y");
assert!(regions[0].col_start >= 5, "col includes area.x");
assert_eq!(regions[0].target, "u");
}
#[test]
fn extract_preserves_plain_text_and_emits_no_regions() {
let area = ratatui::layout::Rect::new(0, 0, 20, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
"just plain text",
)])],
area,
);
let before = row_text(&buf, 0, 0, 15);
let regions = extract_buffer_link_regions(&mut buf, area);
let after = row_text(&buf, 0, 0, 15);
assert!(regions.is_empty());
assert_eq!(before, after, "plain text untouched");
}
#[test]
fn extract_blanks_unterminated_payload_and_emits_no_region() {
let area = ratatui::layout::Rect::new(0, 0, 12, 1);
let mut buf = render_lines(
vec![ratatui::text::Line::from(vec![ratatui::text::Span::raw(
"\x1b]8;;t\x1b\\lab",
)])],
area,
);
let regions = extract_buffer_link_regions(&mut buf, area);
assert!(regions.is_empty(), "no close -> no region");
let text = row_text(&buf, 0, 0, 12);
assert!(!text.contains(']'), "open payload blanked");
}
}