use std::sync::atomic::{AtomicBool, Ordering};
static HYPERLINKS_SUPPORTED: AtomicBool = AtomicBool::new(true);
static HYPERLINKS_CHECKED: AtomicBool = AtomicBool::new(false);
pub fn supports_hyperlinks() -> bool {
if !HYPERLINKS_CHECKED.load(Ordering::SeqCst) {
let supported = detect_hyperlink_support();
HYPERLINKS_SUPPORTED.store(supported, Ordering::SeqCst);
HYPERLINKS_CHECKED.store(true, Ordering::SeqCst);
}
HYPERLINKS_SUPPORTED.load(Ordering::SeqCst)
}
pub fn set_hyperlinks_supported(supported: bool) {
HYPERLINKS_SUPPORTED.store(supported, Ordering::SeqCst);
HYPERLINKS_CHECKED.store(true, Ordering::SeqCst);
}
fn detect_hyperlink_support() -> bool {
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let term_lower = term_program.to_lowercase();
if term_lower.contains("iterm")
|| term_lower.contains("hyper")
|| term_lower.contains("wezterm")
|| term_lower.contains("kitty")
|| term_lower.contains("alacritty")
{
return true;
}
}
if std::env::var("WT_SESSION").is_ok() {
return true;
}
if let Ok(vte_version) = std::env::var("VTE_VERSION") {
if let Ok(version) = vte_version.parse::<u32>() {
if version >= 5000 {
return true;
}
}
}
if let Ok(colorterm) = std::env::var("COLORTERM") {
if colorterm == "truecolor" || colorterm == "24bit" {
return true;
}
}
if std::env::var("KONSOLE_VERSION").is_ok() {
return true;
}
false
}
#[derive(Debug, Clone)]
pub struct Hyperlink {
url: String,
text: String,
id: Option<String>,
}
impl Hyperlink {
pub fn new(url: impl Into<String>, text: impl Into<String>) -> Self {
Self {
url: url.into(),
text: text.into(),
id: None,
}
}
pub fn url(url: impl Into<String>) -> Self {
let url = url.into();
Self {
text: url.clone(),
url,
id: None,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn get_url(&self) -> &str {
&self.url
}
pub fn get_text(&self) -> &str {
&self.text
}
pub fn render(&self) -> String {
if supports_hyperlinks() {
self.render_osc8()
} else {
self.render_fallback()
}
}
pub fn render_osc8(&self) -> String {
let id_param = match &self.id {
Some(id) => format!("id={}", id),
None => String::new(),
};
format!(
"\x1b]8;{};{}\x1b\\{}\x1b]8;;\x1b\\",
id_param, self.url, self.text
)
}
pub fn render_fallback(&self) -> String {
if self.text == self.url {
self.text.clone()
} else {
format!("{} ({})", self.text, self.url)
}
}
pub fn render_with_fallback<F>(&self, fallback: F) -> String
where
F: FnOnce(&str, &str) -> String,
{
if supports_hyperlinks() {
self.render_osc8()
} else {
fallback(&self.text, &self.url)
}
}
}
#[derive(Debug, Clone)]
pub struct HyperlinkBuilder {
hyperlink: Hyperlink,
color: Option<crate::core::Color>,
underline: bool,
bold: bool,
}
impl HyperlinkBuilder {
pub fn new(url: impl Into<String>, text: impl Into<String>) -> Self {
Self {
hyperlink: Hyperlink::new(url, text),
color: Some(crate::core::Color::Blue),
underline: true,
bold: false,
}
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.hyperlink.id = Some(id.into());
self
}
pub fn color(mut self, color: crate::core::Color) -> Self {
self.color = Some(color);
self
}
pub fn no_color(mut self) -> Self {
self.color = None;
self
}
pub fn underline(mut self, underline: bool) -> Self {
self.underline = underline;
self
}
pub fn bold(mut self, bold: bool) -> Self {
self.bold = bold;
self
}
pub fn render(&self) -> String {
let mut result = String::new();
if self.bold {
result.push_str("\x1b[1m");
}
if self.underline {
result.push_str("\x1b[4m");
}
if let Some(color) = &self.color {
result.push_str(&color.to_ansi_fg());
}
result.push_str(&self.hyperlink.render());
result.push_str("\x1b[0m");
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn test_lock() -> &'static Mutex<()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn test_hyperlink_creation() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::new("https://example.com", "Example");
assert_eq!(link.get_url(), "https://example.com");
assert_eq!(link.get_text(), "Example");
}
#[test]
fn test_hyperlink_url() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::url("https://example.com");
assert_eq!(link.get_url(), "https://example.com");
assert_eq!(link.get_text(), "https://example.com");
}
#[test]
fn test_hyperlink_with_id() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::new("https://example.com", "Example").with_id("link1");
assert_eq!(link.id, Some("link1".to_string()));
}
#[test]
fn test_hyperlink_render_osc8() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::new("https://example.com", "Example");
let rendered = link.render_osc8();
assert!(rendered.contains("\x1b]8;"));
assert!(rendered.contains("https://example.com"));
assert!(rendered.contains("Example"));
assert!(rendered.contains("\x1b]8;;\x1b\\"));
}
#[test]
fn test_hyperlink_render_osc8_with_id() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::new("https://example.com", "Example").with_id("mylink");
let rendered = link.render_osc8();
assert!(rendered.contains("id=mylink"));
}
#[test]
fn test_hyperlink_render_fallback() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::new("https://example.com", "Example");
let fallback = link.render_fallback();
assert_eq!(fallback, "Example (https://example.com)");
}
#[test]
fn test_hyperlink_render_fallback_same_text() {
let _guard = test_lock().lock().unwrap();
let link = Hyperlink::url("https://example.com");
let fallback = link.render_fallback();
assert_eq!(fallback, "https://example.com");
}
#[test]
fn test_hyperlink_builder() {
let _guard = test_lock().lock().unwrap();
let builder = HyperlinkBuilder::new("https://example.com", "Example")
.color(crate::core::Color::Cyan)
.underline(true)
.bold(true);
let rendered = builder.render();
assert!(rendered.contains("\x1b[1m")); assert!(rendered.contains("\x1b[4m")); assert!(rendered.contains("\x1b[0m")); }
#[test]
fn test_hyperlink_builder_no_style() {
let _guard = test_lock().lock().unwrap();
let builder = HyperlinkBuilder::new("https://example.com", "Example")
.no_color()
.underline(false)
.bold(false);
let rendered = builder.render();
assert!(rendered.ends_with("\x1b[0m"));
}
#[test]
fn test_set_hyperlinks_supported() {
let _guard = test_lock().lock().unwrap();
set_hyperlinks_supported(true);
assert!(supports_hyperlinks());
set_hyperlinks_supported(false);
assert!(!supports_hyperlinks());
HYPERLINKS_CHECKED.store(false, Ordering::SeqCst);
}
#[test]
fn test_render_with_fallback() {
let _guard = test_lock().lock().unwrap();
set_hyperlinks_supported(false);
let link = Hyperlink::new("https://example.com", "Example");
let result = link.render_with_fallback(|text, url| format!("[{}]({})", text, url));
assert_eq!(result, "[Example](https://example.com)");
HYPERLINKS_CHECKED.store(false, Ordering::SeqCst);
}
}