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 7, 2023
1 parent 773a306 commit 592c20a
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 6 deletions.
49 changes: 46 additions & 3 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::SetTitle) => {
write!(f, "\x1B]2;")?;
}
Some(OSControl::SetIcon) => {
write!(f, "\x1B]I;")?;
}
Some(OSControl::NotifyCWD) => {
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::SetTitle)|
Some(OSControl::SetIcon) |
Some(OSControl::NotifyCWD) => {
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 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,6 +354,15 @@ impl fmt::Display for Infix {
}
Difference::Reset => {
let f: &mut dyn fmt::Write = f;

match (self.0, self.1) {
(Style{oscontrol: Some(OSControl::Hyperlink), ..},
Style{oscontrol: None, ..}) => {
write!(f, "{}", HYPERLINK_RESET)?;
}
(_,_) => {}
}

write!(f, "{}{}", RESET, self.1.prefix())
}
Difference::Empty => {
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
132 changes: 129 additions & 3 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,98 @@ 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("foo/bar.icn"));
/// 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("/foo/bar"));
/// 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::notifycwd(),
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 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: Cow<'a, S>) {
self.style.hyperlink();
self.params = Some(url);
}

/// Directly access the style
pub const fn style_ref(&self) -> &Style {
&self.style
Expand Down Expand Up @@ -163,6 +258,7 @@ impl Style {
AnsiGenericString {
string: input.into(),
style: self,
params: None,
}
}
}
Expand All @@ -185,6 +281,7 @@ impl Color {
AnsiGenericString {
string: input.into(),
style: self.normal(),
params: None,
}
}
}
Expand Down Expand Up @@ -213,6 +310,13 @@ where
&'a S: AsRef<[u8]>,
{
fn write_to_any<W: AnyWrite<Wstr = S> + ?Sized>(&self, w: &mut W) -> Result<(), W::Error> {
match (&self.params, self.style.oscontrol) {
(Some(s), Some(_)) => {
w.write_str(s.as_ref())?;
write!(w, "\x1B\\")?;
}
_ => {}
}
write!(w, "{}", self.style.prefix())?;
w.write_str(self.string.as_ref())?;
write!(w, "{}", self.style.suffix())
Expand Down Expand Up @@ -251,13 +355,29 @@ where
Some(f) => f,
};

match (&first.params, first.style.oscontrol) {
(Some(s), Some(_)) => {
w.write_str(s.as_ref())?;
write!(w, "\x1B\\")?;
}
_ => {}
}
write!(w, "{}", first.style.prefix())?;
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())?,
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 +389,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 Down
41 changes: 41 additions & 0 deletions src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ pub struct Style {
/// The style's background color, if it has one.
pub background: Option<Color>,

// 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<OSControl>,

/// Whether this style is bold.
pub is_bold: bool,

Expand Down Expand Up @@ -230,6 +236,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::SetTitle);
s
}

pub(crate) fn icon() -> Style {
let mut s = Self::default();
s.oscontrol = Some(OSControl::SetIcon);
s
}

pub(crate) fn notifycwd() -> Style {
let mut s = Self::default();
s.oscontrol = Some(OSControl::NotifyCWD);
s
}

/// Return true if this `Style` has no actual styles, and can be written
/// without any control characters.
///
Expand Down Expand Up @@ -261,6 +290,7 @@ impl Default for Style {
Style {
foreground: None,
background: None,
oscontrol: None,
is_bold: false,
is_dimmed: false,
is_italic: false,
Expand Down Expand Up @@ -578,6 +608,17 @@ impl From<Color> for Style {
}
}

#[non_exhaustive]
#[derive(Eq, PartialEq, Clone, Copy)]
pub(crate) enum OSControl {
Hyperlink,
SetTitle,
SetIcon,
NotifyCWD,
//ScrollMarkerPromptBegin, // \e[?7711l
//ScrollMarkerPromptEnd, // \e[?7711h
}

#[cfg(test)]
#[cfg(feature = "derive_serde_style")]
mod serde_json_tests {
Expand Down

0 comments on commit 592c20a

Please sign in to comment.