Skip to content

Commit ccf7075

Browse files
authored
Support for OSC 8 hyperlinks (#1038)
Add `style::StartHyperlink` and `style::EndHyperlink` commands for OSC 8 hyperlink support. The URL is generic over `AsRef<str>`, which lets callers pass borrowed or owned string-like values without forcing allocation, and optional OSC 8 parameters can be added with the `param()` builder. A hyperlink can be emitted with: ```rust use crossterm::style::{self, EndHyperlink, Print, StartHyperlink}; execute!( io::stdout(), StartHyperlink::new("https://example.com"), Print("click here"), EndHyperlink, )?; ```
1 parent 2df28f7 commit ccf7075

5 files changed

Lines changed: 190 additions & 1 deletion

File tree

examples/link.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! Demonstrates OSC 8 hyperlink support.
2+
//!
3+
//! cargo run --example link
4+
5+
use std::io;
6+
7+
use crossterm::{
8+
execute,
9+
style::{EndHyperlink, Print, StartHyperlink},
10+
};
11+
12+
fn main() -> io::Result<()> {
13+
let mut out = io::stdout();
14+
15+
execute!(
16+
out,
17+
Print("Visit: "),
18+
StartHyperlink::new("https://github.com/crossterm-rs/crossterm"),
19+
Print("crossterm"),
20+
EndHyperlink,
21+
Print("\n"),
22+
)
23+
}

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
//! [`ResetColor`](style/struct.ResetColor.html), [`SetColors`](style/struct.SetColors.html)
5757
//! - Attributes - [`SetAttribute`](style/struct.SetAttribute.html), [`SetAttributes`](style/struct.SetAttributes.html),
5858
//! [`PrintStyledContent`](style/struct.PrintStyledContent.html)
59+
//! - Hyperlinks - [`StartHyperlink`](style/struct.StartHyperlink.html),
60+
//! [`EndHyperlink`](style/struct.EndHyperlink.html)
5961
//! - Module [`terminal`](terminal/index.html)
6062
//! - Scrolling - [`ScrollUp`](terminal/struct.ScrollUp.html),
6163
//! [`ScrollDown`](terminal/struct.ScrollDown.html)

src/macros.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,20 @@ macro_rules! execute {
129129
#[doc(hidden)]
130130
#[macro_export]
131131
macro_rules! impl_display {
132+
(for $t:ident<T> where T: $bound:path) => {
133+
impl<T: $bound> ::std::fmt::Display for $t<T> {
134+
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
135+
$crate::command::execute_fmt(f, self)
136+
}
137+
}
138+
};
132139
(for $($t:ty),+) => {
133140
$(impl ::std::fmt::Display for $t {
134141
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
135142
$crate::command::execute_fmt(f, self)
136143
}
137144
})*
138-
}
145+
};
139146
}
140147

141148
#[doc(hidden)]

src/style.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,15 @@ use crate::{csi, impl_display, Command};
122122
pub use self::{
123123
attributes::Attributes,
124124
content_style::ContentStyle,
125+
hyperlink::{EndHyperlink, StartHyperlink},
125126
styled_content::StyledContent,
126127
stylize::Stylize,
127128
types::{Attribute, Color, Colored, Colors},
128129
};
129130

130131
mod attributes;
131132
mod content_style;
133+
mod hyperlink;
132134
mod styled_content;
133135
mod stylize;
134136
mod sys;

src/style/hyperlink.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
use std::fmt;
2+
3+
use crate::{impl_display, osc, Command};
4+
5+
/// A command that starts an [OSC 8 hyperlink].
6+
///
7+
/// Text printed after this command will be a clickable hyperlink in
8+
/// supported terminals until [`EndHyperlink`] is printed.
9+
///
10+
/// [OSC 8 hyperlink]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
11+
///
12+
/// # Notes
13+
///
14+
/// Commands must be executed/queued for execution otherwise they do nothing.
15+
#[derive(Debug, Clone, PartialEq, Eq)]
16+
pub struct StartHyperlink<T> {
17+
pub url: T,
18+
pub params: Vec<(String, String)>,
19+
}
20+
21+
impl<T> StartHyperlink<T>
22+
where
23+
T: AsRef<str>,
24+
{
25+
pub fn new(url: T) -> Self {
26+
Self {
27+
url,
28+
params: Vec::new(),
29+
}
30+
}
31+
32+
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
33+
self.params.push((key.into(), value.into()));
34+
self
35+
}
36+
}
37+
38+
impl<T> Command for StartHyperlink<T>
39+
where
40+
T: AsRef<str>,
41+
{
42+
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
43+
// OSC 8 ; params ; uri ST
44+
// params are key=value pairs separated by ':'
45+
f.write_str("\x1B]8;")?;
46+
for (i, (k, v)) in self.params.iter().enumerate() {
47+
if i > 0 {
48+
f.write_char(':')?;
49+
}
50+
f.write_str(k)?;
51+
f.write_char('=')?;
52+
f.write_str(v)?;
53+
}
54+
f.write_char(';')?;
55+
f.write_str(self.url.as_ref())?;
56+
f.write_str("\x1B\\")
57+
}
58+
59+
#[cfg(windows)]
60+
fn execute_winapi(&self) -> std::io::Result<()> {
61+
Ok(())
62+
}
63+
}
64+
65+
/// A command that ends an [OSC 8 hyperlink].
66+
///
67+
/// [OSC 8 hyperlink]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
68+
///
69+
/// # Notes
70+
///
71+
/// Commands must be executed/queued for execution otherwise they do nothing.
72+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73+
pub struct EndHyperlink;
74+
75+
impl Command for EndHyperlink {
76+
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
77+
f.write_str(osc!("8;;"))
78+
}
79+
80+
#[cfg(windows)]
81+
fn execute_winapi(&self) -> std::io::Result<()> {
82+
Ok(())
83+
}
84+
}
85+
86+
impl_display!(for StartHyperlink<T> where T: AsRef<str>);
87+
impl_display!(for EndHyperlink);
88+
89+
#[cfg(test)]
90+
mod tests {
91+
use std::borrow::Cow;
92+
93+
use super::*;
94+
95+
#[test]
96+
fn start_no_params() {
97+
let mut buf = String::new();
98+
StartHyperlink::new("https://example.com")
99+
.write_ansi(&mut buf)
100+
.unwrap();
101+
assert_eq!(buf, "\x1B]8;;https://example.com\x1B\\");
102+
}
103+
104+
#[test]
105+
fn start_with_param() {
106+
let mut buf = String::new();
107+
StartHyperlink::new("https://example.com")
108+
.param("id", "link1")
109+
.write_ansi(&mut buf)
110+
.unwrap();
111+
assert_eq!(buf, "\x1B]8;id=link1;https://example.com\x1B\\");
112+
}
113+
114+
#[test]
115+
fn start_with_multiple_params() {
116+
let mut buf = String::new();
117+
StartHyperlink::new("https://example.com")
118+
.param("id", "link1")
119+
.param(String::from("foo"), String::from("bar"))
120+
.param(Cow::Borrowed("baz"), Cow::Borrowed("fuz"))
121+
.write_ansi(&mut buf)
122+
.unwrap();
123+
assert_eq!(
124+
buf,
125+
"\x1B]8;id=link1:foo=bar:baz=fuz;https://example.com\x1B\\"
126+
);
127+
}
128+
129+
#[test]
130+
fn start_owned_string() {
131+
let mut buf = String::new();
132+
StartHyperlink::new(String::from("https://example.com"))
133+
.param("id", "link1")
134+
.write_ansi(&mut buf)
135+
.unwrap();
136+
assert_eq!(buf, "\x1B]8;id=link1;https://example.com\x1B\\");
137+
}
138+
139+
#[test]
140+
fn start_cow() {
141+
let mut buf = String::new();
142+
StartHyperlink::new(Cow::Borrowed("https://example.com"))
143+
.param("id", "link1")
144+
.write_ansi(&mut buf)
145+
.unwrap();
146+
assert_eq!(buf, "\x1B]8;id=link1;https://example.com\x1B\\");
147+
}
148+
149+
#[test]
150+
fn end_hyperlink() {
151+
let mut buf = String::new();
152+
EndHyperlink.write_ansi(&mut buf).unwrap();
153+
assert_eq!(buf, "\x1B]8;;\x1B\\");
154+
}
155+
}

0 commit comments

Comments
 (0)