From e83da1dd5915ee4fd4af0f84d4448023fcb72b95 Mon Sep 17 00:00:00 2001 From: Josh Triplett Date: Wed, 22 Apr 2020 16:29:31 -0700 Subject: [PATCH] Add hyperlink support Some terminals support hyperlinks to URLs as a text style, defined at https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda . Add support for these escape sequences to ansi_term, storing the hyperlink target as an Option>. This avoids copying URLs when modifying styles. This makes Style no longer Copy, so Style now requires .clone() when duplicating it. Note that this intentionally omits support for the `id` attribute, used by screen-oriented applications to group separated links together as "the same link". This arises when splitting links across lines within a windowing or window-splitting mechanism. Applications with such use cases will need other screen-oriented escape sequences that ansi_term doesn't cover, as well. --- src/ansi.rs | 68 +++++++++++++++++++++++++++++++++-------------- src/difference.rs | 19 ++++++++++++- src/display.rs | 20 +++++++------- src/style.rs | 64 ++++++++++++++++++++++++++++++++++---------- 4 files changed, 126 insertions(+), 45 deletions(-) diff --git a/src/ansi.rs b/src/ansi.rs index aaf2152..846381e 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -59,17 +59,23 @@ impl Style { // All the codes end with an `m`, because reasons. write!(f, "m")?; + if let Some(url) = self.hyperlink_url.as_deref() { + write!(f, "\x1B]8;;{}\x1B\\", url)?; + } + Ok(()) } /// Write any bytes that go *after* a piece of text to the given writer. fn write_suffix(&self, f: &mut W) -> Result<(), W::Error> { if self.is_plain() { - Ok(()) + return Ok(()); } - else { - write!(f, "{}", RESET) + if self.hyperlink_url.is_some() { + write!(f, "{}", RESET_HYPERLINK)?; } + write!(f, "{}", RESET)?; + Ok(()) } } @@ -77,6 +83,8 @@ impl Style { /// The code to send to reset all styles and return to `Style::default()`. pub static RESET: &str = "\x1B[0m"; +/// The code to reset hyperlinks. +pub static RESET_HYPERLINK: &str = "\x1B]8;;\x1B\\"; impl Colour { @@ -118,7 +126,7 @@ impl Colour { /// `std::fmt` formatting without doing any extra allocation, and written to a /// string with the `.to_string()` method. For examples, see /// [`Style::prefix`](struct.Style.html#method.prefix). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Prefix(Style); /// Like `ANSIString`, but only displays the difference between two @@ -128,7 +136,7 @@ pub struct Prefix(Style); /// `std::fmt` formatting without doing any extra allocation, and written to a /// string with the `.to_string()` method. For examples, see /// [`Style::infix`](struct.Style.html#method.infix). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Infix(Style, Style); /// Like `ANSIString`, but only displays the style suffix. @@ -137,7 +145,7 @@ pub struct Infix(Style, Style); /// `std::fmt` formatting without doing any extra allocation, and written to a /// string with the `.to_string()` method. For examples, see /// [`Style::suffix`](struct.Style.html#method.suffix). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct Suffix(Style); @@ -163,8 +171,8 @@ impl Style { /// assert_eq!("", /// style.prefix().to_string()); /// ``` - pub fn prefix(self) -> Prefix { - Prefix(self) + pub fn prefix(&self) -> Prefix { + Prefix(self.clone()) } /// The infix bytes between this style and `next` style. These are the bytes @@ -178,18 +186,18 @@ impl Style { /// /// let style = Style::default().bold(); /// assert_eq!("\x1b[32m", - /// style.infix(Green.bold()).to_string()); + /// style.infix(&Green.bold()).to_string()); /// /// let style = Green.normal(); /// assert_eq!("\x1b[1m", - /// style.infix(Green.bold()).to_string()); + /// style.infix(&Green.bold()).to_string()); /// /// let style = Style::default(); /// assert_eq!("", - /// style.infix(style).to_string()); + /// style.infix(&style).to_string()); /// ``` - pub fn infix(self, next: Style) -> Infix { - Infix(self, next) + pub fn infix(&self, next: &Style) -> Infix { + Infix(self.clone(), next.clone()) } /// The suffix for this style. These are the bytes that tell the terminal @@ -212,8 +220,8 @@ impl Style { /// assert_eq!("", /// style.suffix().to_string()); /// ``` - pub fn suffix(self) -> Suffix { - Suffix(self) + pub fn suffix(&self) -> Suffix { + Suffix(self.clone()) } } @@ -295,6 +303,10 @@ impl fmt::Display for Infix { let f: &mut fmt::Write = f; write!(f, "{}{}", RESET, self.1.prefix()) }, + Difference::ResetHyperlink => { + let f: &mut fmt::Write = f; + write!(f, "{}{}{}", RESET_HYPERLINK, RESET, self.1.prefix()) + }, Difference::NoDifference => { Ok(()) // nothing to write }, @@ -362,13 +374,29 @@ mod test { test!(reverse: Style::new().reverse(); "hi" => "\x1B[7mhi\x1B[0m"); test!(hidden: Style::new().hidden(); "hi" => "\x1B[8mhi\x1B[0m"); test!(stricken: Style::new().strikethrough(); "hi" => "\x1B[9mhi\x1B[0m"); + test!(hyperlink_plain: Style::new().hyperlink("url"); "hi" => "\x1B[m\x1B]8;;url\x1B\\hi\x1B]8;;\x1B\\\x1B[0m"); + test!(hyperlink_color: Blue.hyperlink("url"); "hi" => "\x1B[34m\x1B]8;;url\x1B\\hi\x1B]8;;\x1B\\\x1B[0m"); + test!(hyperlink_style: Blue.underline().hyperlink("url"); "hi" => "\x1B[4;34m\x1B]8;;url\x1B\\hi\x1B]8;;\x1B\\\x1B[0m"); #[test] fn test_infix() { - assert_eq!(Style::new().dimmed().infix(Style::new()).to_string(), "\x1B[0m"); - assert_eq!(White.dimmed().infix(White.normal()).to_string(), "\x1B[0m\x1B[37m"); - assert_eq!(White.normal().infix(White.bold()).to_string(), "\x1B[1m"); - assert_eq!(White.normal().infix(Blue.normal()).to_string(), "\x1B[34m"); - assert_eq!(Blue.bold().infix(Blue.bold()).to_string(), ""); + assert_eq!(Style::new().dimmed().infix(&Style::new()).to_string(), "\x1B[0m"); + assert_eq!(White.dimmed().infix(&White.normal()).to_string(), "\x1B[0m\x1B[37m"); + assert_eq!(White.normal().infix(&White.bold()).to_string(), "\x1B[1m"); + assert_eq!(White.normal().infix(&Blue.normal()).to_string(), "\x1B[34m"); + assert_eq!(Blue.bold().infix(&Blue.bold()).to_string(), ""); + } + + #[test] + fn test_infix_hyperlink() { + assert_eq!(Blue.hyperlink("url1").infix(&Style::new()).to_string(), "\x1B]8;;\x1B\\\x1B[0m"); + assert_eq!(Blue.hyperlink("url1").infix(&Red.normal()).to_string(), "\x1B]8;;\x1B\\\x1B[0m\x1B[31m"); + assert_eq!(Blue.normal().infix(&Red.hyperlink("url2")).to_string(), "\x1B[31m\x1B]8;;url2\x1B\\"); + assert_eq!(Blue.hyperlink("url1").infix(&Red.hyperlink("url2")).to_string(), "\x1B[31m\x1B]8;;url2\x1B\\"); + assert_eq!(Blue.underline().hyperlink("url1").infix(&Red.italic().hyperlink("url2")).to_string(), "\x1B[0m\x1B[3;31m\x1B]8;;url2\x1B\\"); + + assert_eq!(Style::new().hyperlink("url1").infix(&Style::new().hyperlink("url1")).to_string(), ""); + assert_eq!(Blue.hyperlink("url1").infix(&Red.hyperlink("url1")).to_string(), "\x1B[31m"); + assert_eq!(Blue.underline().hyperlink("url1").infix(&Red.underline().hyperlink("url1")).to_string(), "\x1B[31m"); } } diff --git a/src/difference.rs b/src/difference.rs index b0de07f..57724c5 100644 --- a/src/difference.rs +++ b/src/difference.rs @@ -3,7 +3,7 @@ use super::Style; /// When printing out one coloured string followed by another, use one of /// these rules to figure out which *extra* control codes need to be sent. -#[derive(PartialEq, Clone, Copy, Debug)] +#[derive(PartialEq, Clone, Debug)] pub enum Difference { /// Print out the control codes specified by this style to end up looking @@ -14,6 +14,11 @@ pub enum Difference { /// command and then the second string's styles. Reset, + /// Converting between these two is impossible, and the first includes a + /// hyperlink, so send a reset and a hyperlink reset, then the second + /// string's styles. + ResetHyperlink, + /// The before style is exactly the same as the after style, so no further /// control codes need to be printed. NoDifference, @@ -47,6 +52,10 @@ impl Difference { return NoDifference; } + if first.hyperlink_url.is_some() && next.hyperlink_url.is_none() { + return ResetHyperlink; + } + // Cannot un-bold, so must Reset. if first.is_bold && !next.is_bold { return Reset; @@ -133,6 +142,10 @@ impl Difference { extra_styles.background = next.background; } + if first.hyperlink_url != next.hyperlink_url { + extra_styles.hyperlink_url = next.hyperlink_url.clone(); + } + ExtraStyles(extra_styles) } } @@ -170,10 +183,14 @@ mod test { test!(addition_of_hidden: style(); style().hidden() => ExtraStyles(style().hidden())); test!(addition_of_reverse: style(); style().reverse() => ExtraStyles(style().reverse())); test!(addition_of_strikethrough: style(); style().strikethrough() => ExtraStyles(style().strikethrough())); + test!(addition_of_hyperlink: style(); style().hyperlink("x") => ExtraStyles(style().hyperlink("x"))); test!(removal_of_strikethrough: style().strikethrough(); style() => Reset); test!(removal_of_reverse: style().reverse(); style() => Reset); test!(removal_of_hidden: style().hidden(); style() => Reset); test!(removal_of_dimmed: style().dimmed(); style() => Reset); test!(removal_of_blink: style().blink(); style() => Reset); + test!(removal_of_hyperlink: style().hyperlink("x"); style() => ResetHyperlink); + + test!(change_of_hyperlink: style().hyperlink("url1"); style().hyperlink("url2") => ExtraStyles(style().hyperlink("url2"))); } diff --git a/src/display.rs b/src/display.rs index 17c54f0..ef1d59b 100644 --- a/src/display.rs +++ b/src/display.rs @@ -3,7 +3,7 @@ use std::fmt; use std::io; use std::ops::Deref; -use ansi::RESET; +use ansi::{RESET, RESET_HYPERLINK}; use difference::Difference; use style::{Style, Colour}; use write::AnyWrite; @@ -35,7 +35,7 @@ impl<'a, S: 'a + ToOwned + ?Sized> Clone for ANSIGenericString<'a, S> where ::Owned: fmt::Debug { fn clone(&self) -> ANSIGenericString<'a, S> { ANSIGenericString { - style: self.style, + style: self.style.clone(), string: self.string.clone(), } } @@ -161,12 +161,12 @@ impl Style { /// Paints the given text with this colour, returning an ANSI string. #[must_use] - pub fn paint<'a, I, S: 'a + ToOwned + ?Sized>(self, input: I) -> ANSIGenericString<'a, S> + pub fn paint<'a, I, S: 'a + ToOwned + ?Sized>(&self, input: I) -> ANSIGenericString<'a, S> where I: Into>, ::Owned: fmt::Debug { ANSIGenericString { string: input.into(), - style: self, + style: self.clone(), } } } @@ -258,19 +258,19 @@ where ::Owned: fmt::Debug, &'a S: AsRef<[u8]> { match Difference::between(&window[0].style, &window[1].style) { ExtraStyles(style) => write!(w, "{}", style.prefix())?, Reset => write!(w, "{}{}", RESET, window[1].style.prefix())?, + ResetHyperlink => { + write!(w, "{}{}{}", RESET_HYPERLINK, RESET, window[1].style.prefix())?; + } NoDifference => {/* Do nothing! */}, } w.write_str(&window[1].string)?; } - // Write the final reset string after all of the ANSIStrings have been - // written, *except* if the last one has no styles, because it would - // have already been written by this point. + // Write any final reset strings needed after all of the ANSIStrings + // have been written. if let Some(last) = self.0.last() { - if !last.style.is_plain() { - write!(w, "{}", RESET)?; - } + write!(w, "{}", last.style.suffix())?; } Ok(()) diff --git a/src/style.rs b/src/style.rs index 1bee4d9..2e291be 100644 --- a/src/style.rs +++ b/src/style.rs @@ -1,3 +1,5 @@ +use std::rc::Rc; + /// A style is a collection of properties that can format a string /// using ANSI escape codes. /// @@ -9,7 +11,7 @@ /// let style = Style::new().bold().on(Colour::Black); /// println!("{}", style.paint("Bold on black")); /// ``` -#[derive(PartialEq, Clone, Copy)] +#[derive(PartialEq, Clone)] #[cfg_attr(feature = "derive_serde_style", derive(serde::Deserialize, serde::Serialize))] pub struct Style { @@ -41,7 +43,10 @@ pub struct Style { pub is_hidden: bool, /// Whether this style is struckthrough. - pub is_strikethrough: bool + pub is_strikethrough: bool, + + /// Hyperlink target for this style + pub hyperlink_url: Option>, } impl Style { @@ -71,7 +76,7 @@ impl Style { /// println!("{}", style.paint("hey")); /// ``` pub fn bold(&self) -> Style { - Style { is_bold: true, .. *self } + Style { is_bold: true, ..self.clone() } } /// Returns a `Style` with the dimmed property set. @@ -85,7 +90,7 @@ impl Style { /// println!("{}", style.paint("sup")); /// ``` pub fn dimmed(&self) -> Style { - Style { is_dimmed: true, .. *self } + Style { is_dimmed: true, ..self.clone() } } /// Returns a `Style` with the italic property set. @@ -99,7 +104,7 @@ impl Style { /// println!("{}", style.paint("greetings")); /// ``` pub fn italic(&self) -> Style { - Style { is_italic: true, .. *self } + Style { is_italic: true, ..self.clone() } } /// Returns a `Style` with the underline property set. @@ -113,7 +118,7 @@ impl Style { /// println!("{}", style.paint("salutations")); /// ``` pub fn underline(&self) -> Style { - Style { is_underline: true, .. *self } + Style { is_underline: true, ..self.clone() } } /// Returns a `Style` with the blink property set. @@ -126,7 +131,7 @@ impl Style { /// println!("{}", style.paint("wazzup")); /// ``` pub fn blink(&self) -> Style { - Style { is_blink: true, .. *self } + Style { is_blink: true, ..self.clone() } } /// Returns a `Style` with the reverse property set. @@ -140,7 +145,7 @@ impl Style { /// println!("{}", style.paint("aloha")); /// ``` pub fn reverse(&self) -> Style { - Style { is_reverse: true, .. *self } + Style { is_reverse: true, ..self.clone() } } /// Returns a `Style` with the hidden property set. @@ -154,7 +159,7 @@ impl Style { /// println!("{}", style.paint("ahoy")); /// ``` pub fn hidden(&self) -> Style { - Style { is_hidden: true, .. *self } + Style { is_hidden: true, ..self.clone() } } /// Returns a `Style` with the strikethrough property set. @@ -168,7 +173,7 @@ impl Style { /// println!("{}", style.paint("yo")); /// ``` pub fn strikethrough(&self) -> Style { - Style { is_strikethrough: true, .. *self } + Style { is_strikethrough: true, ..self.clone() } } /// Returns a `Style` with the foreground colour property set. @@ -182,7 +187,7 @@ impl Style { /// println!("{}", style.paint("hi")); /// ``` pub fn fg(&self, foreground: Colour) -> Style { - Style { foreground: Some(foreground), .. *self } + Style { foreground: Some(foreground), ..self.clone() } } /// Returns a `Style` with the background colour property set. @@ -196,7 +201,21 @@ impl Style { /// println!("{}", style.paint("eyyyy")); /// ``` pub fn on(&self, background: Colour) -> Style { - Style { background: Some(background), .. *self } + Style { background: Some(background), ..self.clone() } + } + + /// Returns a `Style` with the hyperlink URL set. You can pass the + /// hyperlink as either a `String` or a `&str`. + /// + /// # Examples + /// + /// ``` + /// use ansi_term::Style; + /// let style = Style::new().hyperlink("https://example.org/"); + /// println!("{}", style.paint("example link")); + /// ``` + pub fn hyperlink>>(&self, url: U) -> Style { + Style { hyperlink_url: Some(url.into()), ..self.clone() } } /// Return true if this `Style` has no actual styles, and can be written @@ -210,8 +229,8 @@ impl Style { /// assert_eq!(true, Style::default().is_plain()); /// assert_eq!(false, Style::default().bold().is_plain()); /// ``` - pub fn is_plain(self) -> bool { - self == Style::default() + pub fn is_plain(&self) -> bool { + self == &Style::default() } } @@ -239,6 +258,7 @@ impl Default for Style { is_reverse: false, is_hidden: false, is_strikethrough: false, + hyperlink_url: None, } } } @@ -458,6 +478,22 @@ impl Colour { pub fn on(self, background: Colour) -> Style { Style { foreground: Some(self), background: Some(background), .. Style::default() } } + + /// Returns a `Style` with the foreground colour set to this colour and the + /// hyperlink URL set. You can pass the hyperlink as either a `String` or a + /// `&str`. + /// + /// # Examples + /// + /// ``` + /// use ansi_term::Colour; + /// + /// let style = Colour::Blue.hyperlink("https://example.org/"); + /// println!("{}", style.paint("example link")); + /// ``` + pub fn hyperlink>>(self, url: U) -> Style { + Style { foreground: Some(self), hyperlink_url: Some(url.into()), ..Style::default() } + } } impl From for Style {