From 1db72231152db145034826feb6f1c0ec794bc31b Mon Sep 17 00:00:00 2001 From: Matt Helsley Date: Wed, 3 May 2023 23:52:24 -0700 Subject: [PATCH] Add support for hyperlinks and other OSC codes 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: https://github.com/ogham/rust-ansi-term/pull/61) The above proposed change breaks the Copy trait for Style and would require changing downstream projects that rely on it. 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 than taken in the referenced pull request. 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. These let us keep the prefix/suffix handling consistent. 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. 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. --- src/ansi.rs | 165 +++++++++++++++++++----------- src/difference.rs | 12 +++ src/display.rs | 250 ++++++++++++++++++++++++++++++++++++++++++++-- src/style.rs | 62 +++++++++++- 4 files changed, 424 insertions(+), 65 deletions(-) diff --git a/src/ansi.rs b/src/ansi.rs index 901e160..dcc163e 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -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; @@ -13,75 +13,95 @@ impl Style { return Ok(()); } - // Prefix everything with reset characters if needed - if self.with_reset { - write!(f, "\x1B[0m")? - } + if self.has_sgr() { + // Prefix everything with reset characters if needed + if self.with_reset { + write!(f, "\x1B[0m")? + } - // Write the codes’ prefix, then write numbers, separated by - // semicolons, for each text style we want to apply. - write!(f, "\x1B[")?; - let mut written_anything = false; + // "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[")?; + let mut written_anything = false; + + { + let mut write_char = |c| { + if written_anything { + write!(f, ";")?; + } + written_anything = true; + #[cfg(feature = "gnu_legacy")] + write!(f, "0")?; + write!(f, "{}", c)?; + Ok(()) + }; + + if self.is_bold { + write_char('1')? + } + if self.is_dimmed { + write_char('2')? + } + if self.is_italic { + write_char('3')? + } + if self.is_underline { + write_char('4')? + } + if self.is_blink { + write_char('5')? + } + if self.is_reverse { + write_char('7')? + } + if self.is_hidden { + write_char('8')? + } + if self.is_strikethrough { + write_char('9')? + } + } - { - let mut write_char = |c| { + // The foreground and background colors, if specified, need to be + // handled specially because the number codes are more complicated. + // (see `write_background_code` and `write_foreground_code`) + if let Some(bg) = self.background { if written_anything { write!(f, ";")?; } written_anything = true; - #[cfg(feature = "gnu_legacy")] - write!(f, "0")?; - write!(f, "{}", c)?; - Ok(()) - }; - - if self.is_bold { - write_char('1')? - } - if self.is_dimmed { - write_char('2')? - } - if self.is_italic { - write_char('3')? - } - if self.is_underline { - write_char('4')? - } - if self.is_blink { - write_char('5')? - } - if self.is_reverse { - write_char('7')? - } - if self.is_hidden { - write_char('8')? + bg.write_background_code(f)?; } - if self.is_strikethrough { - write_char('9')? - } - } - // The foreground and background colors, if specified, need to be - // handled specially because the number codes are more complicated. - // (see `write_background_code` and `write_foreground_code`) - if let Some(bg) = self.background { - if written_anything { - write!(f, ";")?; + if let Some(fg) = self.foreground { + if written_anything { + write!(f, ";")?; + } + fg.write_foreground_code(f)?; } - written_anything = true; - bg.write_background_code(f)?; + + // All the SGR codes end with an `m`, because reasons. + write!(f, "m")?; } - if let Some(fg) = self.foreground { - if written_anything { - write!(f, ";")?; + // 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;")?; } - fg.write_foreground_code(f)?; + None => {} } - // All the codes end with an `m`, because reasons. - write!(f, "m")?; - Ok(()) } @@ -90,7 +110,19 @@ 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) + } + _ => { + write!(f, "{}", RESET) + } + } } } } @@ -98,6 +130,10 @@ impl Style { /// 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(&self, f: &mut W) -> Result<(), W::Error> { match self { @@ -362,7 +398,20 @@ 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()) + }, + (Style{oscontrol: Some(_), ..}, + Style{oscontrol: None, ..}) => { + write!(f, "{}{}", ST, self.1.prefix()) + }, + (_,_) => { + write!(f, "{}{}", RESET, self.1.prefix()) + } + } } Difference::Empty => { Ok(()) // nothing to write diff --git a/src/difference.rs b/src/difference.rs index 30a88f1..f1b5ce4 100644 --- a/src/difference.rs +++ b/src/difference.rs @@ -43,6 +43,10 @@ impl Difference { return Empty; } + if first.has_sgr() && !next.has_sgr() { + return Reset; + } + // Cannot un-bold, so must Reset. if first.is_bold && !next.is_bold { return Reset; @@ -87,6 +91,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 { @@ -129,6 +137,10 @@ impl Difference { extra_styles.background = next.background; } + if first.oscontrol != next.oscontrol { + extra_styles.oscontrol = next.oscontrol; + } + ExtraStyles(extra_styles) } } diff --git a/src/display.rs b/src/display.rs index c066ccd..98320ad 100644 --- a/src/display.rs +++ b/src/display.rs @@ -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; @@ -16,6 +16,7 @@ where { pub(crate) style: Style, pub(crate) string: Cow<'a, S>, + pub(crate) params: Option>, } /// Cloning an `AnsiGenericString` will clone its underlying string. @@ -37,6 +38,7 @@ where AnsiGenericString { style: self.style, string: self.string.clone(), + params: self.params.clone(), } } } @@ -98,6 +100,7 @@ where AnsiGenericString { string: input.into(), style: Style::default(), + params: None, } } } @@ -106,6 +109,100 @@ impl<'a, S: 'a + ToOwned + ?Sized> AnsiGenericString<'a, S> where ::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(title: I) -> AnsiGenericString<'a, S> + where + I: Into> + { + 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(path: I) -> AnsiGenericString<'a, S> + where + I: Into> + { + 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(path: I) -> AnsiGenericString<'a, S> + where + I: Into> + { + 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(&mut self, url: I) + where + I: Into> + { + self.style.hyperlink(); + self.params = Some(url.into()); + } + /// Directly access the style pub const fn style_ref(&self) -> &Style { &self.style @@ -163,6 +260,7 @@ impl Style { AnsiGenericString { string: input.into(), style: self, + params: None, } } } @@ -185,6 +283,7 @@ impl Color { AnsiGenericString { string: input.into(), style: self.normal(), + params: None, } } } @@ -214,6 +313,13 @@ where { fn write_to_any + ?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()) } @@ -252,12 +358,42 @@ 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, RESET, window[1].style.prefix())?; + } + (Style{oscontrol: Some(_), ..}, + Style{oscontrol: None, ..}) => { + write!(w, "\x1B\\{}", window[1].style.prefix())?; + } + (_,_) => { + write!(w, "{}{}", RESET, window[1].style.prefix())?; + } + } + }, Empty => { /* Do nothing! */ } } @@ -269,7 +405,17 @@ where // have already been written by this point. if let Some(last) = self.0.last() { if !last.style.is_plain() { - write!(w, "{}", RESET)?; + match last.style.oscontrol { + Some(OSControl::Hyperlink) => { + write!(w, "{}{}", HYPERLINK_RESET, RESET)?; + } + Some(_) => { + write!(w, "\x1B\\")?; + } + _ => { + write!(w, "{}", RESET)?; + } + } } } @@ -281,7 +427,7 @@ where #[cfg(test)] mod tests { - pub use super::super::AnsiStrings; + pub use super::super::{AnsiStrings, AnsiGenericString}; pub use crate::style::Color::*; pub use crate::style::Style; @@ -292,4 +438,96 @@ mod tests { let output = AnsiStrings(&[one, two]).to_string(); assert_eq!(output, "onetwo"); } + + // NOTE: unstyled because it could have OSC escape sequences + fn idempotent(unstyled: AnsiGenericString<'_, str>) { + let before_g = Green.paint("Before is Green. "); + let before = Style::default().paint("Before is Plain. "); + let after_g = Green.paint(" After is Green."); + let after = Style::default().paint(" After is Plain."); + let unstyled_s = unstyled.clone().to_string(); + + // check that RESET precedes unstyled + let joined = AnsiStrings(&[before_g.clone(), unstyled.clone()]).to_string(); + assert!(joined.starts_with("\x1B[32mBefore is Green. \x1B[0m")); + assert!(joined.ends_with(unstyled_s.as_str()), + "{:?} does not end with {:?}", joined, unstyled_s); + + // check that RESET does not follow unstyled when appending styled + let joined = AnsiStrings(&[unstyled.clone(), after_g.clone()]).to_string(); + assert!(joined.starts_with(unstyled_s.as_str()), + "{:?} does not start with {:?}", joined, unstyled_s); + assert!(joined.ends_with("\x1B[32m After is Green.\x1B[0m")); + + // does not introduce spurious SGR codes (reset or otherwise) adjacent + // to plain strings + let joined = AnsiStrings(&[unstyled.clone()]).to_string(); + assert!(!joined.contains("\x1B["), + "{:?} does contain \\x1B[", joined); + let joined = AnsiStrings(&[before.clone(), unstyled.clone()]).to_string(); + assert!(!joined.contains("\x1B["), + "{:?} does contain \\x1B[", joined); + let joined = AnsiStrings(&[before.clone(), unstyled.clone(), after.clone()]).to_string(); + assert!(!joined.contains("\x1B["), + "{:?} does contain \\x1B[", joined); + let joined = AnsiStrings(&[unstyled.clone(), after.clone()]).to_string(); + assert!(!joined.contains("\x1B["), + "{:?} does contain \\x1B[", joined); + } + + #[test] + fn title() { + let title = Style::title().paint("Test Title"); + assert_eq!(title.clone().to_string(), "\x1B]2;Test Title\x1B\\"); + idempotent(title) + } + + #[test] + fn icon() { + let icon = Style::icon().paint("/path/to/test.icn"); + assert_eq!(icon.to_string(), "\x1B]I;/path/to/test.icn\x1B\\"); + idempotent(icon) + } + + #[test] + fn cwd() { + let cwd = Style::cwd().paint("/path/to/test/"); + assert_eq!(cwd.to_string(), "\x1B]7;/path/to/test/\x1B\\"); + idempotent(cwd) + } + + #[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[04;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[04;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[04;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[04;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m"); + } } diff --git a/src/style.rs b/src/style.rs index 154028d..c3e02d8 100644 --- a/src/style.rs +++ b/src/style.rs @@ -21,6 +21,12 @@ pub struct Style { /// The style's background color, if it has one. pub background: Option, + // The style's os control type, if it has one. + // Used by corresponding public API functions in + // AnsiGenericString. This allows us to keep the + // prefix and suffix bits in the Style definition. + pub(crate) oscontrol: Option, + /// Whether this style is bold. pub is_bold: bool, @@ -250,6 +256,29 @@ impl Style { } } + pub(crate) fn hyperlink(&mut self) -> &mut Style { + self.oscontrol = Some(OSControl::Hyperlink); + self + } + + pub(crate) fn title() -> Style { + let mut s = Self::default(); + s.oscontrol = Some(OSControl::Title); + s + } + + pub(crate) fn icon() -> Style { + let mut s = Self::default(); + s.oscontrol = Some(OSControl::Icon); + s + } + + pub(crate) fn cwd() -> Style { + let mut s = Self::default(); + s.oscontrol = Some(OSControl::CWD); + s + } + /// Return true if this `Style` has no actual styles, and can be written /// without any control characters. /// @@ -264,6 +293,21 @@ impl Style { pub fn is_plain(self) -> bool { self == Style::default() } + + #[inline] + pub(crate) fn has_sgr(self) -> bool { + self.foreground.is_some() || + self.background.is_some() || + self.is_bold || + self.is_dimmed || + self.is_italic || + self.is_underline || + self.is_blink || + self.is_reverse || + self.is_hidden || + self.is_strikethrough || + self.with_reset + } } impl Default for Style { @@ -281,6 +325,7 @@ impl Default for Style { Style { foreground: None, background: None, + oscontrol: None, is_bold: false, is_dimmed: false, is_italic: false, @@ -618,6 +663,21 @@ impl From for Style { } } +#[non_exhaustive] +#[derive(Eq, PartialEq, Clone, Copy)] +#[cfg_attr( + feature = "derive_serde_style", + derive(serde::Deserialize, serde::Serialize) +)] +pub(crate) enum OSControl { + Hyperlink, + Title, + Icon, + CWD, + //ScrollMarkerPromptBegin, // \e[?7711l + //ScrollMarkerPromptEnd, // \e[?7711h +} + #[cfg(test)] #[cfg(feature = "derive_serde_style")] mod serde_json_tests { @@ -659,6 +719,6 @@ mod serde_json_tests { fn style_serialization() { let style = Style::default(); - assert_eq!(serde_json::to_string(&style).unwrap(), "{\"foreground\":null,\"background\":null,\"is_bold\":false,\"is_dimmed\":false,\"is_italic\":false,\"is_underline\":false,\"is_blink\":false,\"is_reverse\":false,\"is_hidden\":false,\"is_strikethrough\":false,\"with_reset\":false}".to_string()); + assert_eq!(serde_json::to_string(&style).unwrap(), "{\"foreground\":null,\"background\":null,\"oscontrol\":null,\"is_bold\":false,\"is_dimmed\":false,\"is_italic\":false,\"is_underline\":false,\"is_blink\":false,\"is_reverse\":false,\"is_hidden\":false,\"is_strikethrough\":false,\"with_reset\":false}".to_string()); } }