diff --git a/Cargo.lock b/Cargo.lock index 0b84cad..de952e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,6 +392,7 @@ dependencies = [ "rgb", "terminal-trx", "windows-sys 0.59.0", + "xterm-color", ] [[package]] diff --git a/crates/terminal-colorsaurus/Cargo.toml b/crates/terminal-colorsaurus/Cargo.toml index ad4585e..4e0371c 100644 --- a/crates/terminal-colorsaurus/Cargo.toml +++ b/crates/terminal-colorsaurus/Cargo.toml @@ -11,27 +11,24 @@ edition = "2021" rust-version = "1.70.0" # Search for `FIXME(msrv)` when bumping. exclude = [".github", ".gitignore", "*.sh", "benchmark/**/*", "doc/issues.md", "deny.toml"] -[features] -default = ["query"] -query = ["dep:memchr", "dep:mio", "dep:terminal-trx", "dep:libc", "dep:windows-sys"] - [dependencies] rgb = { version = "0.8.37", optional = true } anstyle = { version = "1.0.7", optional = true } cfg-if = "1.0.0" +xterm-color = { path = "../xterm-color", version = "1.0" } [target.'cfg(any(unix, windows))'.dependencies] -memchr = { version = "2.7.1", optional = true } -terminal-trx = { version = "0.2.3", optional = true } +memchr = "2.7.1" +terminal-trx = "0.2.3" [target.'cfg(unix)'.dependencies] -mio = { version = "1", features = ["os-ext"], default-features = false, optional = true } +mio = { version = "1", features = ["os-ext"], default-features = false } [target.'cfg(target_os = "macos")'.dependencies] -libc = { version = "0.2.151", optional = true } +libc = "0.2.151" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59.0", features = ["Win32_System_Threading"], optional = true } # Keep this in sync with terminal-trx's version to avoid duplicate deps. +windows-sys = { version = "0.59.0", features = ["Win32_System_Threading"] } # Keep this in sync with terminal-trx's version to avoid duplicate deps. [lints] workspace = true diff --git a/crates/terminal-colorsaurus/src/color.rs b/crates/terminal-colorsaurus/src/color.rs index 8623f10..8336387 100644 --- a/crates/terminal-colorsaurus/src/color.rs +++ b/crates/terminal-colorsaurus/src/color.rs @@ -21,7 +21,7 @@ impl Color { /// let is_dark = color.perceived_lightness() <= 50; /// ``` pub fn perceived_lightness(&self) -> u8 { - luminance_to_perceived_lightness(luminance(self)) + (self.perceived_lightness_f32() * 100.) as u8 } /// Converts the color to 8 bit precision per channel by scaling each channel. @@ -41,6 +41,11 @@ impl Color { scale_to_u8(self.b), ) } + + pub(crate) fn perceived_lightness_f32(&self) -> f32 { + let color = xterm_color::Color::rgb(self.r, self.g, self.b); + color.perceived_lightness() + } } fn scale_to_u8(channel: u16) -> u8 { @@ -85,34 +90,6 @@ impl From for anstyle::RgbColor { } } -// Implementation of determining the perceived lightness -// follows this excellent answer: https://stackoverflow.com/a/56678483 - -fn srgb_to_lin(channel: f64) -> f64 { - if channel < 0.04045 { - channel / 12.92 - } else { - ((channel + 0.055) / 1.055).powf(2.4) - } -} - -// Luminance (Y) -fn luminance(color: &Color) -> f64 { - let r = f64::from(color.r) / f64::from(u16::MAX); - let g = f64::from(color.g) / f64::from(u16::MAX); - let b = f64::from(color.b) / f64::from(u16::MAX); - 0.2126 * srgb_to_lin(r) + 0.7152 * srgb_to_lin(g) + 0.0722 * srgb_to_lin(b) -} - -// Perceptual lightness (L*) -fn luminance_to_perceived_lightness(luminance: f64) -> u8 { - if luminance < 216. / 24389. { - (luminance * (24389. / 27.)) as u8 - } else { - (luminance.powf(1. / 3.) * 116. - 16.) as u8 - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/terminal-colorsaurus/src/color_scheme_tests.rs b/crates/terminal-colorsaurus/src/color_scheme_tests.rs index 20d2efc..fbdec39 100644 --- a/crates/terminal-colorsaurus/src/color_scheme_tests.rs +++ b/crates/terminal-colorsaurus/src/color_scheme_tests.rs @@ -54,9 +54,9 @@ mod dark { #[test] fn fg_and_bg_both_dark() { for (foreground, background) in [(DARK_GRAY, DARKER_GRAY), (DARKER_GRAY, BLACK)] { - assert!(foreground.perceived_lightness() < PERCEPTUAL_MIDDLE_GRAY); - assert!(background.perceived_lightness() < PERCEPTUAL_MIDDLE_GRAY); - assert!(foreground.perceived_lightness() != background.perceived_lightness()); + assert!(foreground.perceived_lightness_f32() < 0.5); + assert!(background.perceived_lightness_f32() < 0.5); + assert!(foreground.perceived_lightness_f32() != background.perceived_lightness_f32()); let palette = ColorPalette { foreground, @@ -93,9 +93,12 @@ mod light { #[test] fn fg_and_bg_both_light() { for (foreground, background) in [(LIGHT_GRAY, LIGHTER_GRAY), (LIGHTER_GRAY, WHITE)] { - assert!(foreground.perceived_lightness() > PERCEPTUAL_MIDDLE_GRAY); - assert!(background.perceived_lightness() > PERCEPTUAL_MIDDLE_GRAY); - assert!(foreground.perceived_lightness() != background.perceived_lightness()); + assert!(foreground.perceived_lightness_f32() > 0.5); + assert!(background.perceived_lightness_f32() > 0.5); + assert!( + (foreground.perceived_lightness_f32() - background.perceived_lightness_f32()).abs() + >= f32::EPSILON + ); let palette = ColorPalette { foreground, diff --git a/crates/terminal-colorsaurus/src/lib.rs b/crates/terminal-colorsaurus/src/lib.rs index eb364f2..0e8cc41 100644 --- a/crates/terminal-colorsaurus/src/lib.rs +++ b/crates/terminal-colorsaurus/src/lib.rs @@ -45,22 +45,6 @@ mod color; mod error; mod fmt; -/// Low-level utilities for parsing responses to `OSC 10` / `OSC 11` queries. -/// -/// **Hint:** If you are only using this module, then you should probably disable -/// the default features to avoid unnecessary dependencies: -/// -/// ```toml -/// [dependencies] -/// terminal-colorsaurus = { version = "...", default-features = false } -/// ``` -pub mod parse { - pub use crate::xparsecolor::xparsecolor; -} - -mod xparsecolor; - -#[cfg(feature = "query")] cfg_if! { if #[cfg(all(any(unix, windows), not(terminal_colorsaurus_test_unsupported)))] { mod io; @@ -101,7 +85,6 @@ pub use color::*; /// The color palette i.e. foreground and background colors of the terminal. /// Retrieved by calling [`color_palette`]. -#[cfg(feature = "query")] #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct ColorPalette { @@ -115,7 +98,6 @@ pub struct ColorPalette { /// /// The easiest way to retrieve the color scheme /// is by calling [`color_scheme`]. -#[cfg(feature = "query")] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[allow(clippy::exhaustive_enums)] #[doc(alias = "Theme")] @@ -127,18 +109,14 @@ pub enum ColorScheme { Light, } -#[cfg(feature = "query")] -const PERCEPTUAL_MIDDLE_GRAY: u8 = 50; - -#[cfg(feature = "query")] impl ColorPalette { /// Determines if the terminal uses a dark or light background. pub fn color_scheme(&self) -> ColorScheme { - let fg = self.foreground.perceived_lightness(); - let bg = self.background.perceived_lightness(); + let fg = self.foreground.perceived_lightness_f32(); + let bg = self.background.perceived_lightness_f32(); if bg < fg { ColorScheme::Dark - } else if bg > fg || bg > PERCEPTUAL_MIDDLE_GRAY { + } else if bg > fg || bg > 0.5 { ColorScheme::Light } else { ColorScheme::Dark @@ -152,7 +130,6 @@ pub use error::Error; /// Options to be used with [`foreground_color`] and [`background_color`]. /// You should almost always use the unchanged [`QueryOptions::default`] value. -#[cfg(feature = "query")] #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct QueryOptions { @@ -168,7 +145,6 @@ pub struct QueryOptions { pub timeout: std::time::Duration, } -#[cfg(feature = "query")] impl Default for QueryOptions { fn default() -> Self { Self { @@ -178,7 +154,6 @@ impl Default for QueryOptions { } /// Detects if the terminal is dark or light. -#[cfg(feature = "query")] #[doc = include_str!("../doc/caveats.md")] #[doc(alias = "theme")] pub fn color_scheme(options: QueryOptions) -> Result { @@ -186,7 +161,6 @@ pub fn color_scheme(options: QueryOptions) -> Result { } /// Queries the terminal for it's color scheme (foreground and background color). -#[cfg(feature = "query")] #[doc = include_str!("../doc/caveats.md")] pub fn color_palette(options: QueryOptions) -> Result { imp::color_palette(options) @@ -194,7 +168,6 @@ pub fn color_palette(options: QueryOptions) -> Result { /// Queries the terminal for it's foreground color. \ /// If you also need the foreground color it is more efficient to use [`color_palette`] instead. -#[cfg(feature = "query")] #[doc = include_str!("../doc/caveats.md")] #[doc(alias = "fg")] pub fn foreground_color(options: QueryOptions) -> Result { @@ -203,7 +176,6 @@ pub fn foreground_color(options: QueryOptions) -> Result { /// Queries the terminal for it's background color. \ /// If you also need the foreground color it is more efficient to use [`color_palette`] instead. -#[cfg(feature = "query")] #[doc = include_str!("../doc/caveats.md")] #[doc(alias = "bg")] pub fn background_color(options: QueryOptions) -> Result { diff --git a/crates/terminal-colorsaurus/src/xparsecolor.rs b/crates/terminal-colorsaurus/src/xparsecolor.rs deleted file mode 100644 index bb746e3..0000000 --- a/crates/terminal-colorsaurus/src/xparsecolor.rs +++ /dev/null @@ -1,231 +0,0 @@ -use crate::Color; -use std::str::from_utf8; - -/// Parses the subset of X11 [Color Strings](https://www.x.org/releases/current/doc/libX11/libX11/libX11.html#Color_Strings) -/// emitted by terminals in response to `OSC 10` / `OSC 11` queries. -/// -/// ## Accepted Formats -/// * `#` -/// * `rgb://` -/// * `rgba:///` (rxvt-unicode extension) -/// -/// where ``, `` and ` Option { - if let Some(stripped) = input.strip_prefix(b"#") { - parse_sharp(from_utf8(stripped).ok()?) - } else if let Some(stripped) = input.strip_prefix(b"rgb:") { - parse_rgb(from_utf8(stripped).ok()?) - } else if let Some(stripped) = input.strip_prefix(b"rgba:") { - parse_rgba(from_utf8(stripped).ok()?) - } else { - None - } -} - -/// From the `xparsecolor` man page: -/// > For backward compatibility, an older syntax for RGB Device is supported, -/// > but its continued use is not encouraged. The syntax is an initial sharp sign character -/// > followed by a numeric specification, in one of the following formats: -/// > -/// > The R, G, and B represent single hexadecimal digits. -/// > When fewer than 16 bits each are specified, they represent the most significant bits of the value -/// > (unlike the `rgb:` syntax, in which values are scaled). -/// > For example, the string `#3a7` is the same as `#3000a0007000`. -fn parse_sharp(input: &str) -> Option { - const NUM_COMPONENTS: usize = 3; - let len = input.len(); - if len % NUM_COMPONENTS == 0 && len <= NUM_COMPONENTS * 4 { - let chunk_size = input.len() / NUM_COMPONENTS; - let r = parse_channel_shifted(&input[0..chunk_size])?; - let g = parse_channel_shifted(&input[chunk_size..chunk_size * 2])?; - let b = parse_channel_shifted(&input[chunk_size * 2..])?; - Some(Color { r, g, b }) - } else { - None - } -} - -fn parse_channel_shifted(input: &str) -> Option { - let value = u16::from_str_radix(input, 16).ok()?; - Some(value << ((4 - input.len()) * 4)) -} - -/// From the `xparsecolor` man page: -/// > An RGB Device specification is identified by the prefix `rgb:` and conforms to the following syntax: -/// > ```text -/// > rgb:// -/// > -/// > , , := h | hh | hhh | hhhh -/// > h := single hexadecimal digits (case insignificant) -/// > ``` -/// > Note that *h* indicates the value scaled in 4 bits, -/// > *hh* the value scaled in 8 bits, *hhh* the value scaled in 12 bits, -/// > and *hhhh* the value scaled in 16 bits, respectively. -fn parse_rgb(input: &str) -> Option { - let mut parts = input.split('/'); - let r = parse_channel_scaled(parts.next()?)?; - let g = parse_channel_scaled(parts.next()?)?; - let b = parse_channel_scaled(parts.next()?)?; - if parts.next().is_none() { - Some(Color { r, g, b }) - } else { - None - } -} - -/// Some terminals such as urxvt (rxvt-unicode) optionally support -/// an alpha channel and sometimes return colors in the format `rgba:///`. -/// -/// Dropping the alpha channel is a best-effort thing as -/// the effective color (when combined with a background color) -/// could have a completely different perceived lightness value. -/// -/// Test with `urxvt -depth 32 -fg grey90 -bg rgba:0000/0000/4444/cccc` -fn parse_rgba(input: &str) -> Option { - let mut parts = input.split('/'); - let r = parse_channel_scaled(parts.next()?)?; - let g = parse_channel_scaled(parts.next()?)?; - let b = parse_channel_scaled(parts.next()?)?; - let _a = parse_channel_scaled(parts.next()?)?; - if parts.next().is_none() { - Some(Color { r, g, b }) - } else { - None - } -} - -fn parse_channel_scaled(input: &str) -> Option { - let len = input.len(); - if (1..=4).contains(&len) { - let max = u32::pow(16, len as u32) - 1; - let value = u32::from_str_radix(input, 16).ok()?; - Some((u16::MAX as u32 * value / max) as u16) - } else { - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Tests adapted from alacritty/vte: - // https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2134 - #[test] - fn parses_valid_rgb_color() { - assert_eq!( - xparsecolor(b"rgb:f/e/d"), - Some(Color { - r: 0xffff, - g: 0xeeee, - b: 0xdddd, - }) - ); - assert_eq!( - xparsecolor(b"rgb:11/aa/ff"), - Some(Color { - r: 0x1111, - g: 0xaaaa, - b: 0xffff - }) - ); - assert_eq!( - xparsecolor(b"rgb:f/ed1/cb23"), - Some(Color { - r: 0xffff, - g: 0xed1d, - b: 0xcb23, - }) - ); - assert_eq!( - xparsecolor(b"rgb:ffff/0/0"), - Some(Color { - r: 0xffff, - g: 0x0, - b: 0x0 - }) - ); - } - - #[test] - fn parses_valid_rgba_color() { - assert_eq!( - xparsecolor(b"rgba:0000/0000/4443/cccc"), - Some(Color { - r: 0x0000, - g: 0x0000, - b: 0x4443, - }) - ); - } - - #[test] - fn fails_for_invalid_rgb_color() { - assert!(xparsecolor(b"rgb:").is_none()); // Empty - assert!(xparsecolor(b"rgb:f/f").is_none()); // Not enough channels - assert!(xparsecolor(b"rgb:f/f/f/f").is_none()); // Too many channels - assert!(xparsecolor(b"rgb:f//f").is_none()); // Empty channel - assert!(xparsecolor(b"rgb:ffff/ffff/fffff").is_none()); // Too many digits for one channel - } - - // Tests adapted from alacritty/vte: - // https://github.com/alacritty/vte/blob/ed51aa19b7ad060f62a75ec55ebb802ced850b1a/src/ansi.rs#L2142 - #[test] - fn parses_valid_sharp_color() { - assert_eq!( - xparsecolor(b"#1af"), - Some(Color { - r: 0x1000, - g: 0xa000, - b: 0xf000, - }) - ); - assert_eq!( - xparsecolor(b"#1AF"), - Some(Color { - r: 0x1000, - g: 0xa000, - b: 0xf000, - }) - ); - assert_eq!( - xparsecolor(b"#11aaff"), - Some(Color { - r: 0x1100, - g: 0xaa00, - b: 0xff00 - }) - ); - assert_eq!( - xparsecolor(b"#110aa0ff0"), - Some(Color { - r: 0x1100, - g: 0xaa00, - b: 0xff00 - }) - ); - assert_eq!( - xparsecolor(b"#1100aa00ff00"), - Some(Color { - r: 0x1100, - g: 0xaa00, - b: 0xff00 - }) - ); - assert_eq!( - xparsecolor(b"#123456789ABC"), - Some(Color { - r: 0x1234, - g: 0x5678, - b: 0x9ABC - }) - ); - } - - #[test] - fn fails_for_invalid_sharp_color() { - assert!(xparsecolor(b"#").is_none()); // Empty - assert!(xparsecolor(b"#1234").is_none()); // Not divisible by three - assert!(xparsecolor(b"#123456789ABCDEF").is_none()); // Too many components - } -} diff --git a/crates/terminal-colorsaurus/src/xterm.rs b/crates/terminal-colorsaurus/src/xterm.rs index 4206ef1..f08acd6 100644 --- a/crates/terminal-colorsaurus/src/xterm.rs +++ b/crates/terminal-colorsaurus/src/xterm.rs @@ -1,6 +1,5 @@ use crate::io::{read_until2, TermReader}; use crate::quirks::{terminal_quirks_from_env, TerminalQuirks}; -use crate::xparsecolor::xparsecolor; use crate::{Color, ColorPalette, Error, QueryOptions, Result}; use std::io::{self, BufRead, BufReader, Write as _}; use std::time::Duration; @@ -78,6 +77,16 @@ fn parse_response(response: Vec, prefix: &[u8]) -> Result { .ok_or(Error::Parse(response)) } +fn xparsecolor(input: &[u8]) -> Option { + let xterm_color::Color { + red: r, + green: g, + blue: b, + .. + } = xterm_color::Color::parse(input).ok()?; + Some(Color { r, g, b }) +} + type Reader<'a> = BufReader>>; // We detect terminals that don't support the color query in quite a smart way: