| 1 | //! OSC 8 hyperlink emission and stripping. |
| 2 | //! |
| 3 | //! Modern terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, |
| 4 | //! Alacritty, recent gnome-terminal/konsole) make a substring clickable when |
| 5 | //! it is wrapped in: |
| 6 | //! |
| 7 | //! ```text |
| 8 | //! \x1b]8;;TARGET\x1b\\LABEL\x1b]8;;\x1b\\ |
| 9 | //! ``` |
| 10 | //! |
| 11 | //! Terminals that don't understand the sequence simply render the visible |
| 12 | //! `LABEL` and ignore the escape. So emitting OSC 8 is a strict UX upgrade for |
| 13 | //! supporting terminals and a no-op for the rest. |
| 14 | //! |
| 15 | //! # Architecture (#3029) |
| 16 | //! |
| 17 | //! Link targets never enter `Span::content` or a ratatui `Buffer`. Markdown |
| 18 | //! wrapping produces plain visible spans plus parallel [`LineLink`] metadata. |
| 19 | //! Transcript surfaces translate those relative columns into absolute |
| 20 | //! [`LinkRegion`]s for the current viewport. `ColorCompatBackend::draw` then |
| 21 | //! emits OSC 8 escapes around the corresponding cell runs. This keeps text |
| 22 | //! layout, selection, and clipboard extraction byte-for-byte identical with |
| 23 | //! links enabled or disabled, including long links wrapped across rows. |
| 24 | //! Markdown contributes only normalized HTTP(S) targets, and emission |
| 25 | //! percent-encodes terminal control characters as defense in depth. |
| 26 | //! |
| 27 | //! Opening is terminal-owned: supporting terminals conventionally use |
| 28 | //! Cmd-click on macOS or Ctrl-click on Linux/Windows. CodeWhale does not |
| 29 | //! intercept those gestures or launch URLs itself, so mouse selection remains |
| 30 | //! independent of browser-opening policy. |
| 31 | //! |
| 32 | //! The clipboard/selection extraction path still strips any residual codes via |
| 33 | //! [`strip_into`] / [`strip_ansi_into`] as a defense-in-depth. |
| 34 | |
| 35 | use std::sync::atomic::{AtomicBool, Ordering}; |
| 36 | |
| 37 | const OSC8_PREFIX: &str = "\x1b]8;;"; |
| 38 | const OSC8_TERMINATOR: &str = "\x1b\\"; |
| 39 | const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\"; |
| 40 | |
| 41 | /// A contiguous run of cells on one terminal row that share a hyperlink target. |
| 42 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 43 | pub struct LinkRegion { |
| 44 | pub row: u16, |
| 45 | pub col_start: u16, |
| 46 | pub col_end: u16, |
| 47 | pub target: String, |
| 48 | } |
| 49 | |
| 50 | /// Hyperlink metadata for one already-wrapped visible line. Columns are |
| 51 | /// zero-based display columns relative to that line and `col_end` is |
| 52 | /// inclusive, matching [`LinkRegion`]. |
| 53 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 54 | pub struct LineLink { |
| 55 | pub col_start: usize, |
| 56 | pub col_end: usize, |
| 57 | pub target: String, |
| 58 | } |
| 59 | |
| 60 | impl LineLink { |
| 61 | #[must_use] |
| 62 | pub fn shifted(&self, columns: usize) -> Self { |
| 63 | Self { |
| 64 | col_start: self.col_start.saturating_add(columns), |
| 65 | col_end: self.col_end.saturating_add(columns), |
| 66 | target: self.target.clone(), |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// Translate per-line relative metadata into absolute terminal regions for a |
| 72 | /// rendered viewport. Metadata outside `area` is clipped rather than allowed |
| 73 | /// to hyperlink adjacent chrome (for example the transcript scrollbar). |
| 74 | #[must_use] |
| 75 | pub fn link_regions_for_lines( |
| 76 | area: ratatui::layout::Rect, |
| 77 | links: &[Vec<LineLink>], |
| 78 | ) -> Vec<LinkRegion> { |
| 79 | if area.width == 0 || area.height == 0 { |
| 80 | return Vec::new(); |
| 81 | } |
| 82 | let width = usize::from(area.width); |
| 83 | let mut regions = Vec::new(); |
| 84 | for (line_index, line_links) in links.iter().take(usize::from(area.height)).enumerate() { |
| 85 | let row = area |
| 86 | .y |
| 87 | .saturating_add(u16::try_from(line_index).unwrap_or(u16::MAX)); |
| 88 | for link in line_links { |
| 89 | if link.col_start >= width || link.col_end < link.col_start { |
| 90 | continue; |
| 91 | } |
| 92 | let start = link.col_start; |
| 93 | let end = link.col_end.min(width.saturating_sub(1)); |
| 94 | regions.push(LinkRegion { |
| 95 | row, |
| 96 | col_start: area |
| 97 | .x |
| 98 | .saturating_add(u16::try_from(start).unwrap_or(u16::MAX)), |
| 99 | col_end: area |
| 100 | .x |
| 101 | .saturating_add(u16::try_from(end).unwrap_or(u16::MAX)), |
| 102 | target: link.target.clone(), |
| 103 | }); |
| 104 | } |
| 105 | } |
| 106 | regions |
| 107 | } |
| 108 | |
| 109 | /// Write an OSC 8 hyperlink open sequence for `target` to `w`. |
| 110 | pub fn write_osc8_open(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> { |
| 111 | w.write_all(OSC8_PREFIX.as_bytes())?; |
| 112 | write_sanitized_target(w, target)?; |
| 113 | w.write_all(OSC8_TERMINATOR.as_bytes()) |
| 114 | } |
| 115 | |
| 116 | /// Percent-encode terminal control characters before they enter an OSC |
| 117 | /// parameter. Markdown and restored transcripts are untrusted input: a raw |
| 118 | /// BEL, ESC/ST, or other control byte could terminate the link and inject an |
| 119 | /// arbitrary terminal sequence. Printable Unicode and ordinary URL bytes are |
| 120 | /// preserved byte-for-byte. |
| 121 | fn write_sanitized_target(w: &mut impl std::io::Write, target: &str) -> std::io::Result<()> { |
| 122 | const HEX: &[u8; 16] = b"0123456789ABCDEF"; |
| 123 | let mut encoded = [0u8; 4]; |
| 124 | for ch in target.chars() { |
| 125 | let value = ch.encode_utf8(&mut encoded); |
| 126 | if ch.is_control() { |
| 127 | for &byte in value.as_bytes() { |
| 128 | w.write_all(&[ |
| 129 | b'%', |
| 130 | HEX[usize::from(byte >> 4)], |
| 131 | HEX[usize::from(byte & 0x0f)], |
| 132 | ])?; |
| 133 | } |
| 134 | } else { |
| 135 | w.write_all(value.as_bytes())?; |
| 136 | } |
| 137 | } |
| 138 | Ok(()) |
| 139 | } |
| 140 | |
| 141 | /// Write an OSC 8 hyperlink close sequence to `w`. |
| 142 | pub fn write_osc8_close(w: &mut impl std::io::Write) -> std::io::Result<()> { |
| 143 | w.write_all(OSC8_CLOSE.as_bytes()) |
| 144 | } |
| 145 | |
| 146 | /// Process-wide enable flag. Set once at app init from `[tui] osc8_links` |
| 147 | /// (when present); otherwise defaults to on for macOS/Linux and off for |
| 148 | /// Windows legacy consoles (see `ui.rs`'s `osc8_default_on`). Read by the |
| 149 | /// renderer to gate out-of-band OSC 8 emission. |
| 150 | static ENABLED: AtomicBool = AtomicBool::new(true); |
| 151 | |
| 152 | /// Set the process-wide OSC 8 enable flag. Intended to be called once at |
| 153 | /// startup; subsequent calls take effect immediately. |
| 154 | pub fn set_enabled(enabled: bool) { |
| 155 | ENABLED.store(enabled, Ordering::Relaxed); |
| 156 | } |
| 157 | |
| 158 | /// Whether OSC 8 hyperlink emission is currently enabled. |
| 159 | #[must_use] |
| 160 | pub fn enabled() -> bool { |
| 161 | ENABLED.load(Ordering::Relaxed) |
| 162 | } |
| 163 | |
| 164 | // --- Thread-local link region accumulator (#3029) --- |
| 165 | |
| 166 | use std::cell::RefCell; |
| 167 | |
| 168 | thread_local! { |
| 169 | /// Link regions collected during the current render frame. |
| 170 | /// Populated by transcript widgets from their parallel line metadata; |
| 171 | /// consumed and cleared by `ColorCompatBackend::draw()`. |
| 172 | pub static FRAME_LINKS: RefCell<Vec<LinkRegion>> = const { RefCell::new(Vec::new()) }; |
| 173 | } |
| 174 | |
| 175 | /// Replace the thread-local frame link buffer with `links`. |
| 176 | pub fn set_frame_links(links: Vec<LinkRegion>) { |
| 177 | FRAME_LINKS.with(|cell| { |
| 178 | *cell.borrow_mut() = links; |
| 179 | }); |
| 180 | } |
| 181 | |
| 182 | /// Append `links` to the thread-local frame link buffer. Used when more than |
| 183 | /// one widget renders link-bearing content into the same frame (e.g. the main |
| 184 | /// transcript and the live-transcript overlay): each seam appends rather than |
| 185 | /// replacing, so all regions reach `ColorCompatBackend::draw`. |
| 186 | pub fn append_frame_links(links: Vec<LinkRegion>) { |
| 187 | FRAME_LINKS.with(|cell| cell.borrow_mut().extend(links)); |
| 188 | } |
| 189 | |
| 190 | /// Replace the portion of the current frame-link map covered by an opaque |
| 191 | /// overlay, preserving (and clipping) regions that remain visible around it. |
| 192 | /// This prevents a transcript URL underneath a modal from making unrelated |
| 193 | /// popup text clickable when both widgets paint in the same terminal frame. |
| 194 | pub fn overlay_frame_links(area: ratatui::layout::Rect, links: Vec<LinkRegion>) { |
| 195 | if area.width == 0 || area.height == 0 { |
| 196 | append_frame_links(links); |
| 197 | return; |
| 198 | } |
| 199 | let x_start = area.x; |
| 200 | let x_end = area.right(); |
| 201 | let y_start = area.y; |
| 202 | let y_end = area.bottom(); |
| 203 | FRAME_LINKS.with(|cell| { |
| 204 | let mut current = cell.borrow_mut(); |
| 205 | let mut visible = Vec::with_capacity(current.len().saturating_add(links.len())); |
| 206 | for region in current.drain(..) { |
| 207 | if region.row < y_start |
| 208 | || region.row >= y_end |
| 209 | || region.col_end < x_start |
| 210 | || region.col_start >= x_end |
| 211 | { |
| 212 | visible.push(region); |
| 213 | continue; |
| 214 | } |
| 215 | if region.col_start < x_start { |
| 216 | let mut left = region.clone(); |
| 217 | left.col_end = x_start.saturating_sub(1); |
| 218 | visible.push(left); |
| 219 | } |
| 220 | if region.col_end >= x_end { |
| 221 | let mut right = region; |
| 222 | right.col_start = x_end; |
| 223 | visible.push(right); |
| 224 | } |
| 225 | } |
| 226 | visible.extend(links); |
| 227 | *current = visible; |
| 228 | }); |
| 229 | } |
| 230 | |
| 231 | /// Take the thread-local frame links, leaving an empty vec behind. |
| 232 | pub fn take_frame_links() -> Vec<LinkRegion> { |
| 233 | FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut())) |
| 234 | } |
| 235 | |
| 236 | /// Strip every ANSI escape sequence from `s` into `out`, preserving only the |
| 237 | /// visible characters. ratatui's buffer drops the leading `ESC` byte but |
| 238 | /// happily paints every other byte of an escape (`[`, `0`, `;`, `m`, OSC |
| 239 | /// payloads, etc.) into a buffer cell, drifting columns. Tool stdout that |
| 240 | /// includes ANSI (e.g. `gh`/`git` with color forced on, anything run through |
| 241 | /// a PTY) must be sanitized before it enters the transcript. |
| 242 | /// |
| 243 | /// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS, |
| 244 | /// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers |
| 245 | /// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest. |
| 246 | pub fn strip_ansi_into(s: &str, out: &mut String) { |
| 247 | let bytes = s.as_bytes(); |
| 248 | let mut i = 0; |
| 249 | while i < bytes.len() { |
| 250 | if bytes[i] == 0x1b && i + 1 < bytes.len() { |
| 251 | let next = bytes[i + 1]; |
| 252 | match next { |
| 253 | // CSI: ESC [ ... <final byte 0x40..=0x7E> |
| 254 | b'[' => { |
| 255 | let mut j = i + 2; |
| 256 | while j < bytes.len() { |
| 257 | let b = bytes[j]; |
| 258 | if (0x40..=0x7e).contains(&b) { |
| 259 | j += 1; |
| 260 | break; |
| 261 | } |
| 262 | j += 1; |
| 263 | } |
| 264 | i = j; |
| 265 | continue; |
| 266 | } |
| 267 | // OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL |
| 268 | b']' | b'P' | b'X' | b'^' | b'_' => { |
| 269 | let mut j = i + 2; |
| 270 | while j < bytes.len() { |
| 271 | if bytes[j] == 0x07 { |
| 272 | j += 1; |
| 273 | break; |
| 274 | } |
| 275 | if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { |
| 276 | j += 2; |
| 277 | break; |
| 278 | } |
| 279 | j += 1; |
| 280 | } |
| 281 | i = j; |
| 282 | continue; |
| 283 | } |
| 284 | // Standalone two-byte ESC sequence (RIS, charset selection, etc.) |
| 285 | _ => { |
| 286 | i += 2; |
| 287 | continue; |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | // Strip lone control bytes that ratatui would otherwise drop (and which |
| 292 | // mean nothing in transcript output) but keep \n, \r, \t as legitimate |
| 293 | // formatting. |
| 294 | let b = bytes[i]; |
| 295 | if b < 0x80 { |
| 296 | if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' { |
| 297 | i += 1; |
| 298 | continue; |
| 299 | } |
| 300 | out.push(b as char); |
| 301 | i += 1; |
| 302 | } else { |
| 303 | // UTF-8 multi-byte sequence: copy the whole code point intact. |
| 304 | // Pushing `b as char` would mis-decode it as Latin-1 and mangle |
| 305 | // non-ASCII text (CJK, accented Latin, emoji, …). |
| 306 | let len = utf8_seq_len(b); |
| 307 | let end = (i + len).min(bytes.len()); |
| 308 | if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) { |
| 309 | out.push_str(chunk); |
| 310 | } |
| 311 | i = end; |
| 312 | } |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | /// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back |
| 317 | /// to `1` for continuation bytes / invalid leads so callers always make |
| 318 | /// forward progress. |
| 319 | fn utf8_seq_len(lead: u8) -> usize { |
| 320 | if lead < 0xc0 { |
| 321 | 1 |
| 322 | } else if lead < 0xe0 { |
| 323 | 2 |
| 324 | } else if lead < 0xf0 { |
| 325 | 3 |
| 326 | } else { |
| 327 | 4 |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | /// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible |
| 332 | /// label text. Other escapes (color, style) pass through untouched. The |
| 333 | /// implementation handles both the standard `ESC \` and the lone `BEL` |
| 334 | /// terminators that some emitters use. |
| 335 | pub fn strip_into(s: &str, out: &mut String) { |
| 336 | let bytes = s.as_bytes(); |
| 337 | let mut i = 0; |
| 338 | while i < bytes.len() { |
| 339 | // Look for the OSC 8 prefix `ESC ] 8 ;` |
| 340 | if i + 4 <= bytes.len() |
| 341 | && bytes[i] == 0x1b |
| 342 | && bytes[i + 1] == b']' |
| 343 | && bytes[i + 2] == b'8' |
| 344 | && bytes[i + 3] == b';' |
| 345 | { |
| 346 | // Skip until the string terminator (ESC \) or BEL. |
| 347 | let mut j = i + 4; |
| 348 | while j < bytes.len() { |
| 349 | if bytes[j] == 0x07 { |
| 350 | j += 1; |
| 351 | break; |
| 352 | } |
| 353 | if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { |
| 354 | j += 2; |
| 355 | break; |
| 356 | } |
| 357 | j += 1; |
| 358 | } |
| 359 | i = j; |
| 360 | continue; |
| 361 | } |
| 362 | let b = bytes[i]; |
| 363 | if b < 0x80 { |
| 364 | out.push(b as char); |
| 365 | i += 1; |
| 366 | } else { |
| 367 | let len = utf8_seq_len(b); |
| 368 | let end = (i + len).min(bytes.len()); |
| 369 | if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) { |
| 370 | out.push_str(chunk); |
| 371 | } |
| 372 | i = end; |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | #[cfg(test)] |
| 378 | mod tests { |
| 379 | use super::*; |
| 380 | use std::sync::Mutex; |
| 381 | |
| 382 | /// Serialize tests that read or write the `ENABLED` flag so they don't |
| 383 | /// race each other under cargo's default parallel test runner. |
| 384 | static FLAG_GUARD: Mutex<()> = Mutex::new(()); |
| 385 | |
| 386 | fn strip(s: &str) -> String { |
| 387 | let mut out = String::with_capacity(s.len()); |
| 388 | strip_into(s, &mut out); |
| 389 | out |
| 390 | } |
| 391 | |
| 392 | fn wrapped_link(target: &str, label: &str) -> String { |
| 393 | format!("{OSC8_PREFIX}{target}{OSC8_TERMINATOR}{label}{OSC8_CLOSE}") |
| 394 | } |
| 395 | |
| 396 | #[test] |
| 397 | fn wrapped_link_fixture_is_osc_8_compliant() { |
| 398 | let wrapped = wrapped_link("https://example.com", "click me"); |
| 399 | assert_eq!( |
| 400 | wrapped, |
| 401 | "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\" |
| 402 | ); |
| 403 | } |
| 404 | |
| 405 | #[test] |
| 406 | fn strip_removes_wrapper_keeps_label() { |
| 407 | let wrapped = wrapped_link("https://example.com", "click me"); |
| 408 | assert_eq!(strip(&wrapped), "click me"); |
| 409 | } |
| 410 | |
| 411 | #[test] |
| 412 | fn strip_handles_bel_terminator() { |
| 413 | let wrapped = "\x1b]8;;https://example.com\x07click me\x1b]8;;\x07"; |
| 414 | assert_eq!(strip(wrapped), "click me"); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn strip_passes_through_text_with_no_escapes() { |
| 419 | let plain = "no escapes here"; |
| 420 | assert_eq!(strip(plain), plain); |
| 421 | } |
| 422 | |
| 423 | #[test] |
| 424 | fn strip_preserves_non_osc_8_escapes() { |
| 425 | // Color escape stays in place; only OSC 8 wrappers are removed. |
| 426 | let mixed = format!( |
| 427 | "\x1b[31mred\x1b[0m {wrapped}", |
| 428 | wrapped = wrapped_link("https://example.com", "click") |
| 429 | ); |
| 430 | assert_eq!(strip(&mixed), "\x1b[31mred\x1b[0m click"); |
| 431 | } |
| 432 | |
| 433 | fn strip_ansi(s: &str) -> String { |
| 434 | let mut out = String::with_capacity(s.len()); |
| 435 | strip_ansi_into(s, &mut out); |
| 436 | out |
| 437 | } |
| 438 | |
| 439 | #[test] |
| 440 | fn strip_ansi_removes_csi_sgr_and_keeps_text() { |
| 441 | let coloured = "526 \x1b[1;32mOPEN\x1b[0m bug fix"; |
| 442 | assert_eq!(strip_ansi(coloured), "526 OPEN bug fix"); |
| 443 | } |
| 444 | |
| 445 | #[test] |
| 446 | fn strip_ansi_removes_osc_8_wrapper() { |
| 447 | let wrapped = wrapped_link("https://example.com", "click"); |
| 448 | assert_eq!(strip_ansi(&wrapped), "click"); |
| 449 | } |
| 450 | |
| 451 | #[test] |
| 452 | fn strip_ansi_preserves_newlines_tabs_and_cr() { |
| 453 | let s = "a\nb\tc\rd"; |
| 454 | assert_eq!(strip_ansi(s), "a\nb\tc\rd"); |
| 455 | } |
| 456 | |
| 457 | #[test] |
| 458 | fn strip_ansi_drops_lone_control_bytes() { |
| 459 | // Bare BEL or other C0 control bytes that aren't \n/\r/\t are dropped |
| 460 | // so they can't paint as visible cells. |
| 461 | let s = "a\x07b\x01c"; |
| 462 | assert_eq!(strip_ansi(s), "abc"); |
| 463 | } |
| 464 | |
| 465 | #[test] |
| 466 | fn strip_ansi_preserves_utf8_multibyte_chars() { |
| 467 | // CJK, accented Latin, and emoji must survive the strip without being |
| 468 | // re-decoded as Latin-1 (which would explode 你 -> ä½ ). |
| 469 | let s = "Phase 1: 第一步 README é 🚀"; |
| 470 | assert_eq!(strip_ansi(s), "Phase 1: 第一步 README é 🚀"); |
| 471 | |
| 472 | let coloured = "\x1b[1;32m第一步\x1b[0m done"; |
| 473 | assert_eq!(strip_ansi(coloured), "第一步 done"); |
| 474 | } |
| 475 | |
| 476 | #[test] |
| 477 | fn strip_preserves_utf8_multibyte_chars() { |
| 478 | let wrapped = wrapped_link("https://example.com", "点击我"); |
| 479 | assert_eq!(strip(&wrapped), "点击我"); |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn open_sequence_percent_encodes_target_control_injection() { |
| 484 | let target = "https://safe.test/a\x07b\x1b]8;;https://evil.test\x1b\\c\x7f\u{009c}"; |
| 485 | let mut bytes = Vec::new(); |
| 486 | write_osc8_open(&mut bytes, target).expect("write OSC 8 open"); |
| 487 | let rendered = String::from_utf8(bytes.clone()).expect("valid UTF-8 output"); |
| 488 | |
| 489 | assert_eq!(rendered.matches(OSC8_PREFIX).count(), 1, "{rendered:?}"); |
| 490 | assert_eq!(rendered.matches(OSC8_TERMINATOR).count(), 1, "{rendered:?}"); |
| 491 | assert_eq!(bytes.iter().filter(|byte| **byte == 0x1b).count(), 2); |
| 492 | assert!(!bytes.contains(&0x07), "BEL escaped: {rendered:?}"); |
| 493 | assert!(!bytes.contains(&0x7f), "DEL escaped: {rendered:?}"); |
| 494 | assert!( |
| 495 | rendered.contains("a%07b%1B]8;;https://evil.test%1B\\c%7F%C2%9C"), |
| 496 | "control bytes must be percent-encoded: {rendered:?}" |
| 497 | ); |
| 498 | } |
| 499 | |
| 500 | #[test] |
| 501 | fn enabled_is_true_by_default_when_untouched() { |
| 502 | // Hold the flag guard so we observe the initial state, not a value |
| 503 | // mid-flight from `set_enabled_round_trips`. The flag *defaults* to |
| 504 | // true at static init and tests in this module are the only writers. |
| 505 | let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner()); |
| 506 | assert!(enabled()); |
| 507 | } |
| 508 | |
| 509 | #[test] |
| 510 | fn set_enabled_round_trips() { |
| 511 | let _g = FLAG_GUARD.lock().unwrap_or_else(|e| e.into_inner()); |
| 512 | let prior = enabled(); |
| 513 | set_enabled(false); |
| 514 | assert!(!enabled()); |
| 515 | set_enabled(true); |
| 516 | assert!(enabled()); |
| 517 | set_enabled(prior); |
| 518 | } |
| 519 | |
| 520 | #[test] |
| 521 | fn line_links_translate_to_absolute_clipped_regions() { |
| 522 | let area = ratatui::layout::Rect::new(7, 3, 8, 2); |
| 523 | let links = vec![ |
| 524 | vec![ |
| 525 | LineLink { |
| 526 | col_start: 2, |
| 527 | col_end: 20, |
| 528 | target: "https://example.test/long".to_string(), |
| 529 | }, |
| 530 | LineLink { |
| 531 | col_start: 8, |
| 532 | col_end: 9, |
| 533 | target: "outside".to_string(), |
| 534 | }, |
| 535 | ], |
| 536 | vec![LineLink { |
| 537 | col_start: 0, |
| 538 | col_end: 1, |
| 539 | target: "https://example.test/next".to_string(), |
| 540 | }], |
| 541 | vec![LineLink { |
| 542 | col_start: 0, |
| 543 | col_end: 0, |
| 544 | target: "below viewport".to_string(), |
| 545 | }], |
| 546 | ]; |
| 547 | |
| 548 | assert_eq!( |
| 549 | link_regions_for_lines(area, &links), |
| 550 | vec![ |
| 551 | LinkRegion { |
| 552 | row: 3, |
| 553 | col_start: 9, |
| 554 | col_end: 14, |
| 555 | target: "https://example.test/long".to_string(), |
| 556 | }, |
| 557 | LinkRegion { |
| 558 | row: 4, |
| 559 | col_start: 7, |
| 560 | col_end: 8, |
| 561 | target: "https://example.test/next".to_string(), |
| 562 | }, |
| 563 | ] |
| 564 | ); |
| 565 | } |
| 566 | |
| 567 | #[test] |
| 568 | fn opaque_overlay_replaces_and_clips_underlying_regions() { |
| 569 | set_frame_links(vec![ |
| 570 | LinkRegion { |
| 571 | row: 4, |
| 572 | col_start: 0, |
| 573 | col_end: 20, |
| 574 | target: "under-wide".to_string(), |
| 575 | }, |
| 576 | LinkRegion { |
| 577 | row: 5, |
| 578 | col_start: 6, |
| 579 | col_end: 8, |
| 580 | target: "under-covered".to_string(), |
| 581 | }, |
| 582 | ]); |
| 583 | overlay_frame_links( |
| 584 | ratatui::layout::Rect::new(5, 4, 10, 2), |
| 585 | vec![LinkRegion { |
| 586 | row: 4, |
| 587 | col_start: 7, |
| 588 | col_end: 8, |
| 589 | target: "modal".to_string(), |
| 590 | }], |
| 591 | ); |
| 592 | |
| 593 | assert_eq!( |
| 594 | take_frame_links(), |
| 595 | vec![ |
| 596 | LinkRegion { |
| 597 | row: 4, |
| 598 | col_start: 0, |
| 599 | col_end: 4, |
| 600 | target: "under-wide".to_string(), |
| 601 | }, |
| 602 | LinkRegion { |
| 603 | row: 4, |
| 604 | col_start: 15, |
| 605 | col_end: 20, |
| 606 | target: "under-wide".to_string(), |
| 607 | }, |
| 608 | LinkRegion { |
| 609 | row: 4, |
| 610 | col_start: 7, |
| 611 | col_end: 8, |
| 612 | target: "modal".to_string(), |
| 613 | }, |
| 614 | ] |
| 615 | ); |
| 616 | } |
| 617 | } |
| 618 |