diff --git a/crates/tui/src/tui/color_compat.rs b/crates/tui/src/tui/color_compat.rs index 0a8107ec..31bd24d5 100644 --- a/crates/tui/src/tui/color_compat.rs +++ b/crates/tui/src/tui/color_compat.rs @@ -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 { /// the live crossterm query. terminal_size: Option, render_debug: Option, + /// 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, } impl ColorCompatBackend { @@ -66,9 +72,20 @@ impl ColorCompatBackend { 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) { + 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 Backend for ColorCompatBackend { 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<()> { diff --git a/crates/tui/src/tui/osc8.rs b/crates/tui/src/tui/osc8.rs index 156733be..933708e5 100644 --- a/crates/tui/src/tui/osc8.rs +++ b/crates/tui/src/tui/osc8.rs @@ -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> = 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) { + 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 { + 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. ///