diff --git a/src/ansi.rs b/src/ansi.rs index 901e160..d615289 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, OSControl, Style}; 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,17 @@ 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 +128,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 +396,33 @@ 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..9ff00f0 100644 --- a/src/display.rs +++ b/src/display.rs @@ -1,6 +1,6 @@ -use crate::ansi::RESET; +use crate::ansi::{HYPERLINK_RESET, RESET}; use crate::difference::Difference; -use crate::style::{Color, Style}; +use crate::style::{Color, OSControl, Style}; 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,99 @@ 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 +259,7 @@ impl Style { AnsiGenericString { string: input.into(), style: self, + params: None, } } } @@ -185,6 +282,7 @@ impl Color { AnsiGenericString { string: input.into(), style: self.normal(), + params: None, } } } @@ -214,6 +312,10 @@ where { fn write_to_any + ?Sized>(&self, w: &mut W) -> Result<(), W::Error> { write!(w, "{}", self.style.prefix())?; + if let (Some(s), Some(_)) = (&self.params, self.style.oscontrol) { + w.write_str(s.as_ref())?; + write!(w, "\x1B\\")?; + } w.write_str(self.string.as_ref())?; write!(w, "{}", self.style.suffix()) } @@ -252,12 +354,55 @@ where }; write!(w, "{}", first.style.prefix())?; + if let (Some(s), Some(_)) = (&first.params, first.style.oscontrol) { + 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())?; + if let (Some(OSControl::Hyperlink), Some(s)) = + (style.oscontrol, &window[1].params) + { + 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 +414,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 +436,7 @@ where #[cfg(test)] mod tests { - pub use super::super::AnsiStrings; + pub use super::super::{AnsiGenericString, AnsiStrings}; pub use crate::style::Color::*; pub use crate::style::Style; @@ -292,4 +447,127 @@ 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(); + #[cfg(feature = "gnu_legacy")] + assert_eq!(joined, format!("\x1B[04;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m")); + #[cfg(not(feature = "gnu_legacy"))] + assert_eq!(joined, format!("\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(); + #[cfg(feature = "gnu_legacy")] + assert_eq!(joined, format!("\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")); + #[cfg(not(feature = "gnu_legacy"))] + assert_eq!(joined, format!("\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(); + #[cfg(feature = "gnu_legacy")] + assert_eq!(joined, format!("\x1B[04;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m\x1B[32m After link.\x1B[0m")); + #[cfg(not(feature = "gnu_legacy"))] + assert_eq!(joined, format!("\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(); + #[cfg(feature = "gnu_legacy")] + assert_eq!(joined, format!("\x1B[32mBefore link. \x1B[04;34m\x1B]8;;https://example.com\x1B\\Link to example.com.\x1B]8;;\x1B\\\x1B[0m")); + #[cfg(not(feature = "gnu_legacy"))] + assert_eq!(joined, format!("\x1B[32mBefore link. \x1B[4;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..89057ca 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,32 @@ impl Style { } } + pub(crate) fn hyperlink(&mut self) -> &mut Style { + self.oscontrol = Some(OSControl::Hyperlink); + self + } + + pub(crate) fn title() -> Style { + Self { + oscontrol: Some(OSControl::Title), + ..Default::default() + } + } + + pub(crate) fn icon() -> Style { + Self { + oscontrol: Some(OSControl::Icon), + ..Default::default() + } + } + + pub(crate) fn cwd() -> Style { + Self { + oscontrol: Some(OSControl::Cwd), + ..Default::default() + } + } + /// Return true if this `Style` has no actual styles, and can be written /// without any control characters. /// @@ -264,6 +296,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 +328,7 @@ impl Default for Style { Style { foreground: None, background: None, + oscontrol: None, is_bold: false, is_dimmed: false, is_italic: false, @@ -618,6 +666,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 +722,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()); } }