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