Skip to content

Commit

Permalink
Add support for hyperlinks and other OSC codes
Browse files Browse the repository at this point in the history
Add support for producing colorized/stylized hyperlinks,
among a selection of other OS Control (OSC) codes such
as setting the window title, application/window icon,
and notifying the terminal about the  current working
directory.

There has already been some discussion and a change proposed
for handling hyperlinks in the dormant rust-ansi-term repo:
(See: ogham#61)
The above proposed change breaks the Copy trait for Style
and would require changing downstream projects
(e.g. Starship).

Also, I would argue that these features aren't really
about styling text so much as adding more information for
the terminal emulator to present to the user outside of
the typical area for rendered terminal output.

So this change takes a different approach. An enum
describing the supported OSC codes, which is not exposed
outside the crate, is used to indicate that a Style
has additional terminal prefix and suffix output control
codes to take care of for hyperlinks, titles, etc.

However rather than library users using these enums
directly or calling externally visible functions on Style
or Color struct, AnsiGenericString uses them to implement
its hyperlink(), title(), etc. functions. These store the
hyperlink "src" string, title, etc. within the
AnsiGenericString rather than in the Style.
So Style remains Copy-able, and, since it already stores
strings, AnsiGenericString traits are consistent with this
choice. The locations of the functions better reflect
what's happening because the supplied strings are not meant
to be rendered inline with the ANSI-styled output.

The OSControl enum also nicely describes the subset of OSC
codes the package currently supports and keeps the prefix
and suffix handling neatly adjacent to the Color and Style
prefixes.

--

Sorry if that's a bit verbose. I'm used to carefully
describing even small changes.
  • Loading branch information
Matt Helsley committed May 8, 2023
1 parent 773a306 commit 665a53b
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 9 deletions.
51 changes: 47 additions & 4 deletions src/ansi.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#![allow(missing_docs)]
use crate::style::{Color, Style};
use crate::style::{Color, Style, OSControl};
use crate::write::AnyWrite;
use std::fmt;

Expand All @@ -13,6 +13,7 @@ impl Style {
return Ok(());
}

// "Specified Graphical Rendition" prefixes
// Write the codes’ prefix, then write numbers, separated by
// semicolons, for each text style we want to apply.
write!(f, "\x1B[")?;
Expand Down Expand Up @@ -72,9 +73,26 @@ impl Style {
fg.write_foreground_code(f)?;
}

// All the codes end with an `m`, because reasons.
// All the SGR codes end with an `m`, because reasons.
write!(f, "m")?;

// OS Control (OSC) prefixes
match self.oscontrol {
Some(OSControl::Hyperlink) => {
write!(f, "\x1B]8;;")?;
}
Some(OSControl::Title) => {
write!(f, "\x1B]2;")?;
}
Some(OSControl::Icon) => {
write!(f, "\x1B]I;")?;
}
Some(OSControl::CWD) => {
write!(f, "\x1B]7;")?;
}
None => {}
}

Ok(())
}

Expand All @@ -83,14 +101,30 @@ impl Style {
if self.is_plain() {
Ok(())
} else {
write!(f, "{}", RESET)
match self.oscontrol {
Some(OSControl::Hyperlink) => {
write!(f, "{}{}", HYPERLINK_RESET, RESET)
}
Some(OSControl::Title)|
Some(OSControl::Icon) |
Some(OSControl::CWD) => {
write!(f, "{}{}", ST, RESET)
}
_ => {
write!(f, "{}", RESET)
}
}
}
}
}

/// The code to send to reset all styles and return to `Style::default()`.
pub static RESET: &str = "\x1B[0m";

// The "String Termination" code. Used for OS Control (OSC) sequences.
static ST: &str = "\x1B\\";
pub(crate) static HYPERLINK_RESET: &str = "\x1B]8;;\x1B\\";

impl Color {
fn write_foreground_code<W: AnyWrite + ?Sized>(&self, f: &mut W) -> Result<(), W::Error> {
match self {
Expand Down Expand Up @@ -320,7 +354,16 @@ impl fmt::Display for Infix {
}
Difference::Reset => {
let f: &mut dyn fmt::Write = f;
write!(f, "{}{}", RESET, self.1.prefix())

match (self.0, self.1) {
(Style{oscontrol: Some(OSControl::Hyperlink), ..},
Style{oscontrol: None, ..}) => {
write!(f, "{}{}", HYPERLINK_RESET, self.1.prefix())
},
(_,_) => {
write!(f, "{}{}", RESET, self.1.prefix())
}
}
}
Difference::Empty => {
Ok(()) // nothing to write
Expand Down
8 changes: 8 additions & 0 deletions src/difference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ impl Difference {
return Reset;
}

if first.oscontrol.is_some() && next.oscontrol.is_none() {
return Reset;
}

let mut extra_styles = Style::default();

if first.is_bold != next.is_bold {
Expand Down Expand Up @@ -129,6 +133,10 @@ impl Difference {
extra_styles.background = next.background;
}

if first.oscontrol != next.oscontrol {
extra_styles.oscontrol = next.oscontrol;
}

ExtraStyles(extra_styles)
}
}
Expand Down
180 changes: 176 additions & 4 deletions src/display.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::ansi::RESET;
use crate::ansi::{RESET, HYPERLINK_RESET};
use crate::difference::Difference;
use crate::style::{Color, Style};
use crate::style::{Color, Style, OSControl};
use crate::write::AnyWrite;
use std::borrow::Cow;
use std::fmt;
Expand All @@ -16,6 +16,7 @@ where
{
pub(crate) style: Style,
pub(crate) string: Cow<'a, S>,
pub(crate) params: Option<Cow<'a, S>>,
}

/// Cloning an `AnsiGenericString` will clone its underlying string.
Expand All @@ -37,6 +38,7 @@ where
AnsiGenericString {
style: self.style,
string: self.string.clone(),
params: self.params.clone(),
}
}
}
Expand Down Expand Up @@ -98,6 +100,7 @@ where
AnsiGenericString {
string: input.into(),
style: Style::default(),
params: None,
}
}
}
Expand All @@ -106,6 +109,100 @@ impl<'a, S: 'a + ToOwned + ?Sized> AnsiGenericString<'a, S>
where
<S as ToOwned>::Owned: fmt::Debug,
{

/// Produce an ANSI string that changes the title shown
/// by the terminal emulator.
///
/// # Examples
///
/// ```
/// use nu_ansi_term::AnsiString;
/// let title_string = AnsiString::title("My Title");
/// println!("{}", title_string);
/// ```
/// Should produce an empty line but set the terminal title.
pub fn title<I>(title: I) -> AnsiGenericString<'a, S>
where
I: Into<Cow<'a, S>>
{
Self {
string: title.into(),
style: Style::title(),
params: None,
}
}

/// Produce an ANSI string that notifies the terminal
/// emulator that the running application is better
/// represented by the icon found at a given path.
///
/// # Examples
///
/// ```
/// use nu_ansi_term::AnsiString;
/// let icon_string = AnsiString::icon(std::path::Path::new("foo/bar.icn").to_string_lossy());
/// println!("{}", icon_string);
/// ```
/// Should produce an empty line but set the terminal icon.
/// Notice that we use std::path to be portable.
pub fn icon<I>(path: I) -> AnsiGenericString<'a, S>
where
I: Into<Cow<'a, S>>
{
Self {
string: path.into(),
style: Style::icon(),
params: None,
}
}

/// Produce an ANSI string that notifies the terminal
/// emulator the current working directory has changed
/// to the given path.
///
/// # Examples
///
/// ```
/// use nu_ansi_term::AnsiString;
/// let cwd_string = AnsiString::cwd(std::path::Path::new("/foo/bar").to_string_lossy());
/// println!("{}", cwd_string);
/// ```
/// Should produce an empty line but inform the terminal emulator
/// that the current directory is /foo/bar.
/// Notice that we use std::path to be portable.
pub fn cwd<I>(path: I) -> AnsiGenericString<'a, S>
where
I: Into<Cow<'a, S>>
{
Self {
string: path.into(),
style: Style::cwd(),
params: None,
}
}

/// Cause the styled ANSI string to link to the given URL
///
/// # Examples
///
/// ```
/// use nu_ansi_term::AnsiString;
/// use nu_ansi_term::Color::Red;
///
/// let mut link_string = Red.paint("a red string");
/// link_string.hyperlink("https://www.example.com");
/// println!("{}", link_string);
/// ```
/// Should show a red-painted string which, on terminals
/// that support it, is a clickable hyperlink.
pub fn hyperlink<I>(&mut self, url: I)
where
I: Into<Cow<'a, S>>
{
self.style.hyperlink();
self.params = Some(url.into());
}

/// Directly access the style
pub const fn style_ref(&self) -> &Style {
&self.style
Expand Down Expand Up @@ -163,6 +260,7 @@ impl Style {
AnsiGenericString {
string: input.into(),
style: self,
params: None,
}
}
}
Expand All @@ -185,6 +283,7 @@ impl Color {
AnsiGenericString {
string: input.into(),
style: self.normal(),
params: None,
}
}
}
Expand Down Expand Up @@ -214,6 +313,13 @@ where
{
fn write_to_any<W: AnyWrite<Wstr = S> + ?Sized>(&self, w: &mut W) -> Result<(), W::Error> {
write!(w, "{}", self.style.prefix())?;
match (&self.params, self.style.oscontrol) {
(Some(s), Some(_)) => {
w.write_str(s.as_ref())?;
write!(w, "\x1B\\")?;
}
_ => {}
}
w.write_str(self.string.as_ref())?;
write!(w, "{}", self.style.suffix())
}
Expand Down Expand Up @@ -252,12 +358,37 @@ where
};

write!(w, "{}", first.style.prefix())?;
match (&first.params, first.style.oscontrol) {
(Some(s), Some(_)) => {
w.write_str(s.as_ref())?;
write!(w, "\x1B\\")?;
}
_ => {}
}
w.write_str(first.string.as_ref())?;

for window in self.0.windows(2) {
match Difference::between(&window[0].style, &window[1].style) {
ExtraStyles(style) => write!(w, "{}", style.prefix())?,
Reset => write!(w, "{}{}", RESET, window[1].style.prefix())?,
ExtraStyles(style) => {
write!(w, "{}", style.prefix())?;
match (style.oscontrol, &window[1].params) {
(Some(OSControl::Hyperlink), Some(s)) => {
w.write_str(s.as_ref())?;
write!(w, "\x1B\\")?;
}
_ => {}
}
},
Reset => {
match (&window[0].style, &window[1].style) {
(Style{oscontrol: Some(OSControl::Hyperlink), ..},
Style{oscontrol: None, ..}) => {
write!(w, "{}", HYPERLINK_RESET)?;
}
(_,_) => {}
}
write!(w, "{}{}", RESET, window[1].style.prefix())?;
},
Empty => { /* Do nothing! */ }
}

Expand All @@ -269,6 +400,12 @@ where
// have already been written by this point.
if let Some(last) = self.0.last() {
if !last.style.is_plain() {
match last.style.oscontrol {
Some(OSControl::Hyperlink) => {
write!(w, "{}", HYPERLINK_RESET)?;
}
_ => {}
}
write!(w, "{}", RESET)?;
}
}
Expand All @@ -292,4 +429,39 @@ mod tests {
let output = AnsiStrings(&[one, two]).to_string();
assert_eq!(output, "onetwo");
}

#[test]
fn hyperlink() {
let mut styled = Red.paint("Link to example.com.");
styled.hyperlink("https://example.com");
assert_eq!(styled.to_string(), "\x1B[31m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m");
}

#[test]
fn hyperlinks() {
let before = Green.paint("Before link. ");
let mut link = Blue.underline().paint("Link to example.com.");
let after = Green.paint(" After link.");
link.hyperlink("https://example.com");


// Assemble with link by itself
let joined = AnsiStrings(&[link.clone()]).to_string();
assert_eq!(joined, "\x1B[4;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m");


// Assemble with link in the middle
let joined = AnsiStrings(&[before.clone(), link.clone(), after.clone()]).to_string();
assert_eq!(joined, "\x1B[32mBefore link. \x1B[4;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m\x1B[32m After link.\x1B[0m");


// Assemble with link first
let joined = AnsiStrings(&[link.clone(), after.clone()]).to_string();
assert_eq!(joined, "\x1B[4;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m\x1B[32m After link.\x1B[0m");


// Assemble with link at the end
let joined = AnsiStrings(&[before.clone(), link.clone()]).to_string();
assert_eq!(joined, "\x1B[32mBefore link. \x1B[4;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m");
}
}

0 comments on commit 665a53b

Please sign in to comment.