Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ext/console): convert colors to supported space #18549

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
19 changes: 17 additions & 2 deletions cli/tests/unit/console_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,12 @@ function parseCssColor(colorString: string): [number, number, number] | null {
}

/** ANSI-fy the CSS, replace "\x1b" with "_". */
function cssToAnsiEsc(css: Css, prevCss: Css | null = null): string {
return cssToAnsi_(css, prevCss).replaceAll("\x1b", "_");
function cssToAnsiEsc(
css: Css,
prevCss: Css | null = null,
supportLevel = 3,
): string {
return cssToAnsi_(css, prevCss, supportLevel).replaceAll("\x1b", "_");
}

// test cases from web-platform-tests
Expand Down Expand Up @@ -1291,6 +1295,17 @@ Deno.test(function consoleCssToAnsi() {
);
});

Deno.test(function consoleCssToAnsi256Lower() {
assertEquals(
cssToAnsiEsc(
{ ...DEFAULT_CSS, color: [203, 204, 205], fontWeight: "bold" },
null,
2,
),
"_[38;5;188m_[1m",
);
});

Deno.test(function consoleTestWithVariousOrInvalidFormatSpecifier() {
assertEquals(stringify("%s:%s"), "%s:%s");
assertEquals(stringify("%i:%i"), "%i:%i");
Expand Down
2 changes: 2 additions & 0 deletions cli/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ impl CliMainWorkerFactory {
cpu_count: std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(1),
color_support_level: colors::use_color_support_level(),
log_level: shared.options.log_level,
enable_testing_features: shared.options.enable_testing_features,
locale: deno_core::v8::icu::get_language_tag(),
Expand Down Expand Up @@ -569,6 +570,7 @@ fn create_web_worker_callback(
locale: deno_core::v8::icu::get_language_tag(),
location: Some(args.main_module.clone()),
no_color: !colors::use_color(),
color_support_level: colors::use_color_support_level(),
is_tty: colors::is_tty(),
runtime_version: version::deno().to_string(),
ts_version: version::TYPESCRIPT.to_string(),
Expand Down
86 changes: 77 additions & 9 deletions ext/console/01_console.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ const {
isNaN,
} = primordials;

function assert(cond, msg = "Assertion failed.") {
if (!cond) {
throw new AssertionError(msg);
}
}

let noColor = false;

function setNoColor(value) {
Expand All @@ -147,10 +153,17 @@ function getNoColor() {
return noColor;
}

function assert(cond, msg = "Assertion failed.") {
if (!cond) {
throw new AssertionError(msg);
}
/**
* The level of color the tty supports.
* 0 = None
* 1 = Basic Ansi
* 2 = Ansi 256
* 3 = 24bit True Color
*/
let colorSupportLevel = 0;

function setColorSupportLevel(level) {
colorSupportLevel = level;
}

// Don't use 'blue' not visible on cmd.exe
Expand Down Expand Up @@ -2930,6 +2943,60 @@ function colorEquals(color1, color2) {
color1?.[2] == color2?.[2];
}

const COLOR_SUPPORT_256 = 2;
const COLOR_SUPPORT_TRUE_COLOR = 3;

const ANSI_FOREGROUND = "38";
const ANSI_BACKGROUND = "48";
const ANSI_UNDERLINE = "58";

/**
* Convert an RGB color to the color level that the terminal we're
* running in supports. We'll try to match requested color as close
* as possible to the colors we have available in case it lies outside
* the supported spectrum.
* @param {number} r red
* @param {number} g green
* @param {number} b blue
* @param {"38" | "48"} kind foreground or background color
* @param {0 | 1 | 2 | 3} supportLevel amount of colors supported
* @returns {string}
*/
function rgbToAnsi(r, g, b, kind, supportLevel) {
if (supportLevel === COLOR_SUPPORT_TRUE_COLOR) {
return `\x1b[${kind};2;${r};${g};${b}m`;
} else if (supportLevel === COLOR_SUPPORT_256) {
// Lower colors into 256 color space
// Taken from https://github.com/Qix-/color-convert/blob/3f0e0d4e92e235796ccb17f6e85c72094a651f49/conversions.js
// which is MIT licensed and copyright by Heather Arthur and Josh Junon

// We use the extended greyscale palette here, with the exception of
// black and white. Normal palette only has 4 greyscale shades.
if (r === g && g === b) {
if (r < 8) {
return `\x1b[${kind};5;16`;
}

if (r > 248) {
return `\x1b[${kind};5;231`;
}

const value = MathRound(((r - 8) / 247) * 24) + 232;
return `\x1b[${kind};5;${value}`;
}

const value = 16 +
(36 * MathRound(r / 255 * 5)) +
(6 * MathRound(g / 255 * 5)) +
MathRound(b / 255 * 5);

return `\x1b[${kind};5;${value}m`;
} else {
// No colors if terminal is dumb
return "";
}
}

function cssToAnsi(css, prevCss = null) {
prevCss = prevCss ?? getDefaultCss();
let ansi = "";
Expand All @@ -2955,12 +3022,12 @@ function cssToAnsi(css, prevCss = null) {
} else {
if (ArrayIsArray(css.backgroundColor)) {
const { 0: r, 1: g, 2: b } = css.backgroundColor;
ansi += `\x1b[48;2;${r};${g};${b}m`;
ansi += rgbToAnsi(r, g, b, ANSI_BACKGROUND, colorSupportLevel);
} else {
const parsed = parseCssColor(css.backgroundColor);
if (parsed !== null) {
const { 0: r, 1: g, 2: b } = parsed;
ansi += `\x1b[48;2;${r};${g};${b}m`;
ansi += rgbToAnsi(r, g, b, ANSI_BACKGROUND, colorSupportLevel);
} else {
ansi += "\x1b[49m";
}
Expand Down Expand Up @@ -2989,12 +3056,12 @@ function cssToAnsi(css, prevCss = null) {
} else {
if (ArrayIsArray(css.color)) {
const { 0: r, 1: g, 2: b } = css.color;
ansi += `\x1b[38;2;${r};${g};${b}m`;
ansi += rgbToAnsi(r, g, b, ANSI_FOREGROUND, colorSupportLevel);
} else {
const parsed = parseCssColor(css.color);
if (parsed !== null) {
const { 0: r, 1: g, 2: b } = parsed;
ansi += `\x1b[38;2;${r};${g};${b}m`;
ansi += rgbToAnsi(r, g, b, ANSI_FOREGROUND, colorSupportLevel);
} else {
ansi += "\x1b[39m";
}
Expand All @@ -3018,7 +3085,7 @@ function cssToAnsi(css, prevCss = null) {
if (!colorEquals(css.textDecorationColor, prevCss.textDecorationColor)) {
if (css.textDecorationColor != null) {
const { 0: r, 1: g, 2: b } = css.textDecorationColor;
ansi += `\x1b[58;2;${r};${g};${b}m`;
ansi += rgbToAnsi(r, g, b, ANSI_UNDERLINE, colorSupportLevel);
} else {
ansi += "\x1b[59m";
}
Expand Down Expand Up @@ -3641,6 +3708,7 @@ export {
inspect,
inspectArgs,
quoteString,
setColorSupportLevel,
setNoColor,
styles,
wrapConsole,
Expand Down
131 changes: 131 additions & 0 deletions runtime/colors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,65 @@ static NO_COLOR: Lazy<bool> =

static IS_TTY: Lazy<bool> = Lazy::new(|| atty::is(atty::Stream::Stdout));

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum TTYColorLevel {
None,
Basic,
Ansi256,
TrueColor,
}

static SUPPORT_LEVEL: Lazy<TTYColorLevel> = Lazy::new(|| {
if *NO_COLOR {
return TTYColorLevel::None;
}

fn get_os_env_var(var_name: &str) -> Option<String> {
let var = std::env::var_os(var_name);

var.and_then(|s| {
let maybe_str = s.to_str();
maybe_str.map(|s| s.to_string())
})
}

detect_color_support(get_os_env_var)
});

fn detect_color_support(
get_env_var: impl Fn(&str) -> Option<String>,
) -> TTYColorLevel {
// Windows supports 24bit True Colors since Windows 10 #14931,
// see https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/
if cfg!(target_os = "windows") {
return TTYColorLevel::TrueColor;
}

if let Some(color_term) = get_env_var("COLORTERM") {
if color_term == "truecolor" || color_term == "24bit" {
return TTYColorLevel::TrueColor;
}
}

if let Some(term) = get_env_var("TERM") {
if term.ends_with("256") || term.ends_with("256color") {
return TTYColorLevel::Ansi256;
}

// CI systems commonly set TERM=dumb although they support
// full colors. They usually do their own mapping.
if get_env_var("CI").is_some() {
return TTYColorLevel::TrueColor;
}

if term != "dumb" {
return TTYColorLevel::Basic;
}
}

TTYColorLevel::None
}

pub fn is_tty() -> bool {
*IS_TTY
}
Expand All @@ -35,6 +94,10 @@ pub fn use_color() -> bool {
!(*NO_COLOR)
}

pub fn use_color_support_level() -> TTYColorLevel {
*SUPPORT_LEVEL
}

#[cfg(windows)]
pub fn enable_ansi() {
BufferWriter::stdout(ColorChoice::AlwaysAnsi);
Expand Down Expand Up @@ -155,3 +218,71 @@ pub fn white_bold_on_red<S: AsRef<str>>(s: S) -> impl fmt::Display {
.set_fg(Some(White));
style(s, style_spec)
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;

#[cfg(not(windows))]
#[test]
fn supports_true_color() {
let vars = HashMap::from([("COLORTERM", "truecolor")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::TrueColor
);

let vars = HashMap::from([("COLORTERM", "24bit")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::TrueColor
);
}

#[cfg(not(windows))]
#[test]
fn supports_ansi_256() {
let vars = HashMap::from([("TERM", "xterm-256")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::Ansi256
);

let vars = HashMap::from([("TERM", "xterm-256color")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::Ansi256
);
}

#[cfg(not(windows))]
#[test]
fn supports_ci_color() {
let vars = HashMap::from([("CI", "1"), ("TERM", "dumb")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::TrueColor
);
}

#[cfg(not(windows))]
#[test]
fn supports_basic_ansi() {
let vars = HashMap::from([("TERM", "xterm")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::Basic
);
}

#[cfg(not(windows))]
#[test]
fn supports_none() {
let vars = HashMap::from([("TERM", "dumb")]);
assert_eq!(
detect_color_support(|name| vars.get(name).map(|s| s.to_string())),
TTYColorLevel::None
);
}
}
Loading