feat(tui): OSC 8 out-of-band hyperlink infrastructure (#3029)
Adds the foundation for working OSC 8 hyperlinks in the transcript: - LinkRegion struct: (row, col_start, col_end, target) for a contiguous run of linked cells on one terminal row - write_osc8_open/close: emit OSC 8 escapes directly through a Write impl (bypassing ratatui's buffer which strips ESC bytes) - FRAME_LINKS thread-local: passes link regions from the render closure to ColorCompatBackend::draw(), where OSC 8 escapes are emitted out-of-band through the backend's Write impl - ColorCompatBackend integration: draw() reads FRAME_LINKS, emits OSC 8 open/close around linked cells The markdown renderer still uses the inline Span::content approach (known broken); the sentinel-color buffer-scan integration is a follow-up. This PR delivers the emission path and thread-local plumbing so the remaining work is confined to link detection in the render closure.
This commit is contained in:
@@ -17,6 +17,7 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::palette::{self, ColorDepth, PaletteMode, ThemeId, UiTheme};
|
||||
use crate::tui::osc8::LinkRegion;
|
||||
|
||||
const RENDER_DEBUG_ENV: &str = "CODEWHALE_TUI_DEBUG";
|
||||
const RENDER_DEBUG_SAMPLE_LIMIT: usize = 24;
|
||||
@@ -49,6 +50,11 @@ pub(crate) struct ColorCompatBackend<W: Write> {
|
||||
/// the live crossterm query.
|
||||
terminal_size: Option<Size>,
|
||||
render_debug: Option<RenderDebugLog>,
|
||||
/// OSC 8 link regions to emit during the next `draw()` call (#3029).
|
||||
/// Set by the render closure before `terminal.draw()`; cleared after each
|
||||
/// draw so stale links don't persist across frames.
|
||||
#[allow(dead_code)] // populated via set_pending_links from render closure
|
||||
pending_links: Vec<LinkRegion>,
|
||||
}
|
||||
|
||||
impl<W: Write> ColorCompatBackend<W> {
|
||||
@@ -66,9 +72,20 @@ impl<W: Write> ColorCompatBackend<W> {
|
||||
forced_size: None,
|
||||
terminal_size: None,
|
||||
render_debug: RenderDebugLog::from_env(),
|
||||
pending_links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // called from render closure (future integration)
|
||||
pub(crate) fn set_pending_links(&mut self, links: Vec<LinkRegion>) {
|
||||
self.pending_links = links;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn clear_pending_links(&mut self) {
|
||||
self.pending_links.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn force_size(&mut self, size: Size) {
|
||||
self.forced_size = Some(size);
|
||||
}
|
||||
@@ -129,8 +146,30 @@ impl<W: Write> Backend for ColorCompatBackend<W> {
|
||||
if let Some(render_debug) = &mut self.render_debug {
|
||||
render_debug.record(viewport, &adapted);
|
||||
}
|
||||
// #3029: Emit OSC 8 hyperlinks out-of-band through the backend's
|
||||
// Write impl. ratatui's buffer pipeline strips ESC bytes, so we
|
||||
// queue link open/close around the relevant cells here.
|
||||
let frame_links = crate::tui::osc8::take_frame_links();
|
||||
let link_active = !frame_links.is_empty() && crate::tui::osc8::enabled();
|
||||
if link_active {
|
||||
// For the first pass, emit a single OSC 8 open before the
|
||||
// linked cells and a close after. Proper per-link interleaving
|
||||
// requires sorting cells by position; this is a foundation.
|
||||
for link in &frame_links {
|
||||
let _ = crate::tui::osc8::write_osc8_open(self, &link.target);
|
||||
// Move cursor to link start so the terminal associates
|
||||
// the OSC 8 with cells painted at this position.
|
||||
let _ = self.inner.set_cursor_position((link.col_start, link.row));
|
||||
}
|
||||
}
|
||||
self.inner
|
||||
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))
|
||||
.draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
|
||||
if link_active {
|
||||
for _link in &frame_links {
|
||||
let _ = crate::tui::osc8::write_osc8_close(self);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_lines(&mut self, n: u16) -> io::Result<()> {
|
||||
|
||||
@@ -21,6 +21,29 @@ 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,
|
||||
#[allow(dead_code)] // used by future buffer-scan link detection
|
||||
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 +61,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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user