Merge PR #3039 from Hmbown: OSC 8 out-of-band hyperlink infrastructure

feat(tui): OSC 8 out-of-band hyperlink infrastructure
This commit is contained in:
Hunter Bown
2026-06-10 22:20:35 -07:00
committed by GitHub
2 changed files with 216 additions and 2 deletions
+170 -2
View File
@@ -129,8 +129,52 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
if let Some(render_debug) = &mut self.render_debug {
render_debug.record(viewport, &adapted);
}
self.inner
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))
// #3029: Emit OSC 8 hyperlinks out-of-band through the backend's
// Write impl. ratatui's buffer pipeline strips ESC bytes, so the
// open/close sequences must be interleaved with the cell stream
// here. OSC 8 is stateful and last-writer-wins: every cell painted
// between an open and the next close links to that open's target,
// so each region's cells must be bracketed by their OWN open/close
// pair — never batched.
let mut frame_links = crate::tui::osc8::take_frame_links();
if frame_links.is_empty() || !crate::tui::osc8::enabled() {
self.inner
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
return Ok(());
}
// Deterministic region lookup when regions are adjacent/overlapping:
// the first (top-left-most) region wins.
frame_links.sort_unstable_by_key(|link| (link.row, link.col_start));
let region_for = |x: u16, y: u16| -> Option<usize> {
frame_links
.iter()
.position(|link| y == link.row && x >= link.col_start && x <= link.col_end)
};
// Walk the diff in its original order and split it into runs at
// region boundaries, so the visible byte stream stays identical to
// a no-link render apart from the inserted OSC 8 sequences.
let mut idx = 0;
while idx < adapted.len() {
let current_region = region_for(adapted[idx].0, adapted[idx].1);
let run_start = idx;
while idx < adapted.len()
&& region_for(adapted[idx].0, adapted[idx].1) == current_region
{
idx += 1;
}
let run = &adapted[run_start..idx];
if let Some(region_idx) = current_region {
crate::tui::osc8::write_osc8_open(self, &frame_links[region_idx].target)?;
self.inner
.draw(run.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
crate::tui::osc8::write_osc8_close(self)?;
} else {
self.inner
.draw(run.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
}
}
Ok(())
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
@@ -540,4 +584,128 @@ mod tests {
backend.force_size(Size::new(80, 25));
assert_eq!(backend.size().unwrap(), Size::new(80, 25));
}
// ── #3029: OSC 8 emission through the backend byte stream ──────────────
fn row_cells(symbols: &str) -> Vec<(u16, u16, Cell)> {
symbols
.chars()
.enumerate()
.map(|(i, ch)| {
let mut cell = Cell::default();
cell.set_symbol(&ch.to_string());
(u16::try_from(i).unwrap(), 0u16, cell)
})
.collect()
}
#[test]
fn osc8_open_close_bracket_only_their_region_cells() {
use crate::tui::osc8::LinkRegion;
// Baseline: identical cells, no link regions.
let baseline_writer = SharedWriter::default();
let baseline_capture = baseline_writer.0.clone();
let mut baseline =
ColorCompatBackend::new(baseline_writer, ColorDepth::TrueColor, PaletteMode::Dark);
let cells = row_cells("ABCDE");
baseline
.draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
.unwrap();
let baseline_out = String::from_utf8_lossy(&baseline_capture.borrow()).to_string();
// Linked render: columns 2..=3 ("CD") carry one link region.
crate::tui::osc8::set_frame_links(vec![LinkRegion {
row: 0,
col_start: 2,
col_end: 3,
target: "https://example.test/1".to_string(),
}]);
let writer = SharedWriter::default();
let capture = writer.0.clone();
let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
let cells = row_cells("ABCDE");
backend
.draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
.unwrap();
let out = String::from_utf8_lossy(&capture.borrow()).to_string();
let open = "\x1b]8;;https://example.test/1\x1b\\";
let close = "\x1b]8;;\x1b\\";
assert_eq!(out.matches(open).count(), 1, "exactly one open: {out:?}");
assert_eq!(out.matches(close).count(), 1, "exactly one close: {out:?}");
// The open must precede the first linked glyph and the close must sit
// between the last linked glyph and the first glyph after the region.
let open_at = out.find(open).expect("open present");
let close_at = out.find(close).expect("close present");
let c_at = out.find('C').expect("glyph C");
let d_at = out.find('D').expect("glyph D");
let e_at = out.find('E').expect("glyph E");
assert!(open_at < c_at, "open before linked cells: {out:?}");
assert!(d_at < close_at, "close after linked cells: {out:?}");
assert!(
close_at < e_at,
"cells after the region must not inherit the link: {out:?}"
);
// Visible glyph stream is unchanged by link insertion.
let mut baseline_visible = String::new();
crate::tui::osc8::strip_ansi_into(&baseline_out, &mut baseline_visible);
let mut linked_visible = String::new();
crate::tui::osc8::strip_ansi_into(&out, &mut linked_visible);
assert_eq!(
baseline_visible, linked_visible,
"link emission must not move or alter visible cells"
);
}
#[test]
fn osc8_two_regions_link_to_their_own_targets() {
use crate::tui::osc8::LinkRegion;
crate::tui::osc8::set_frame_links(vec![
LinkRegion {
row: 0,
col_start: 0,
col_end: 1,
target: "https://example.test/first".to_string(),
},
LinkRegion {
row: 0,
col_start: 3,
col_end: 4,
target: "https://example.test/second".to_string(),
},
]);
let writer = SharedWriter::default();
let capture = writer.0.clone();
let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
let cells = row_cells("ABZCD");
backend
.draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
.unwrap();
let out = String::from_utf8_lossy(&capture.borrow()).to_string();
let first = "\x1b]8;;https://example.test/first\x1b\\";
let second = "\x1b]8;;https://example.test/second\x1b\\";
let close = "\x1b]8;;\x1b\\";
assert_eq!(out.matches(first).count(), 1, "{out:?}");
assert_eq!(out.matches(second).count(), 1, "{out:?}");
assert_eq!(out.matches(close).count(), 2, "{out:?}");
// Pre-#3029-audit bug: both opens were emitted before any cell, so
// the whole frame linked to the LAST region's target. Each region's
// open must close before the next region's open begins.
let first_at = out.find(first).expect("first open");
let first_close_at = out[first_at..].find(close).expect("first close") + first_at;
let second_at = out.find(second).expect("second open");
assert!(
first_close_at < second_at,
"region one must close before region two opens: {out:?}"
);
// The unlinked middle glyph sits between the two link spans.
let z_at = out.find('Z').expect("unlinked glyph");
assert!(first_close_at < z_at && z_at < second_at, "{out:?}");
}
}
+46
View File
@@ -21,6 +21,28 @@ use std::sync::atomic::{AtomicBool, Ordering};
const OSC8_PREFIX: &str = "\x1b]8;;";
const OSC8_TERMINATOR: &str = "\x1b\\";
const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
/// A contiguous run of cells on one terminal row that share a hyperlink target.
#[derive(Debug, Clone)]
pub struct LinkRegion {
pub row: u16,
pub col_start: u16,
pub col_end: u16,
pub target: String,
}
/// Write an OSC 8 hyperlink open sequence for `target` to `w`.
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())
}
/// Write an OSC 8 hyperlink close sequence to `w`.
pub fn write_osc8_close(w: &mut impl std::io::Write) -> std::io::Result<()> {
w.write_all(OSC8_CLOSE.as_bytes())
}
/// Process-wide enable flag. `true` by default. Set once at app init from
/// `[ui] osc8_links` (when present) and read by the renderer.
@@ -38,6 +60,30 @@ pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
// --- Thread-local link region accumulator (#3029) ---
use std::cell::RefCell;
thread_local! {
/// Link regions collected during the current render frame.
/// Populated by the render closure after scanning the ratatui buffer;
/// consumed and cleared by `ColorCompatBackend::draw()`.
pub static FRAME_LINKS: RefCell<Vec<LinkRegion>> = const { RefCell::new(Vec::new()) };
}
/// Replace the thread-local frame link buffer with `links`.
#[allow(dead_code)] // called from render closure (future integration)
pub fn set_frame_links(links: Vec<LinkRegion>) {
FRAME_LINKS.with(|cell| {
*cell.borrow_mut() = links;
});
}
/// Take the thread-local frame links, leaving an empty vec behind.
pub fn take_frame_links() -> Vec<LinkRegion> {
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
}
/// Wrap `label` so it links to `target` in OSC 8-aware terminals. The returned
/// string contains the full `\x1b]8;;TARGET\x1b\LABEL\x1b]8;;\x1b\` payload.
///