mirror of
https://github.com/carbon-language/carbon-lang.git
synced 2026-10-05 06:41:06 +01:00
Implement a terminal rendering library in common/terminal (#7597)
Rich diagnostic rendering needs a layer underneath it that knows what the attached terminal can do and can position styled text in two dimensions. This adds that layer, both as the foundation the diagnostics rendering work will build on and as something usable directly for ordinary CLI output. Nothing depends on it yet, so it lands and is reviewed on its own. Four libraries, each with its own tests: - `color`: a color, either one of the 16 named ANSI colors or a 24-bit RGB value, and the escape sequences that select it at a given color depth. - `style`: colors plus text attributes, and the escapes that move a terminal from one style to another. - `capabilities`: what the terminal behind a stream supports, detected from the environment. - `buffer`: a grid of styled cells that layout code draws into and that renders itself once. Rationale for the design decisions lives in the headers, next to what it explains. Four things are worth review attention in particular: - The color detection precedence documented on `ChooseColorMode`. It settles how a `--color` flag, `NO_COLOR`, `CLICOLOR`, `FORCE_COLOR`, and the terminal itself interact. The policy is a pure function of those inputs, so the whole table is tested without touching the process environment. - `Charset`, which decides whether any UTF-8 processing happens at all. Column counts only follow from code points if the terminal agrees about the encoding, so anything short of a locale naming UTF-8 is treated as bytes. - `Buffer` owning column accounting instead of its callers, which is what keeps double-width characters, combining marks, and stray bytes from misaligning everything after them. - The API surface, which is held to operations that nothing else covers. Junctions in line art come only from lines overlapping, and turning a style on or off is spelled as a transition to or from the default style. `terminal_benchmark` covers style transitions, full-screen rendering, and text drawing. On an M-series laptop, rendering an 80x24 screen in which every cell changes style costs about 14us with color off and 76-95us with it, and drawing a 40-column line of source costs about 140ns without UTF-8 processing and 394ns with it. Assisted-by: Gemini and Claude --------- Co-authored-by: Richard Smith <richard@metafoo.co.uk>
This commit is contained in:
co-authored by
Richard Smith
parent
3bbc03f527
commit
9c486de2de
@@ -0,0 +1,287 @@
|
||||
// Part of the Carbon Language project, under the Apache License v2.0 with LLVM
|
||||
// Exceptions. See /LICENSE for license information.
|
||||
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
|
||||
|
||||
#include "common/terminal/capabilities.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "common/filesystem.h"
|
||||
|
||||
namespace Carbon::Terminal {
|
||||
namespace {
|
||||
|
||||
// Detection policy is a pure function of the environment and whether the
|
||||
// stream is a terminal, so these tests build the environment directly instead
|
||||
// of mutating the process environment, which would leak between tests and race
|
||||
// with anything else running.
|
||||
auto OnTerminal(const ColorEnvironment& env) -> ColorMode {
|
||||
return ChooseColorMode(Preference::Auto, env, /*is_terminal=*/true);
|
||||
}
|
||||
|
||||
auto OffTerminal(const ColorEnvironment& env) -> ColorMode {
|
||||
return ChooseColorMode(Preference::Auto, env, /*is_terminal=*/false);
|
||||
}
|
||||
|
||||
// A terminal that supports color, for tests varying one other variable.
|
||||
auto ColorTerminal() -> ColorEnvironment { return {.term = "xterm-256color"}; }
|
||||
|
||||
TEST(CapabilitiesTest, ColorNeedsATerminal) {
|
||||
EXPECT_EQ(OnTerminal(ColorTerminal()), ColorMode::Ansi256);
|
||||
|
||||
// Writing to a file or a pipe must stay plain, or every redirected build log
|
||||
// fills with escape sequences.
|
||||
EXPECT_EQ(OffTerminal(ColorTerminal()), ColorMode::NoColor);
|
||||
|
||||
// A terminal that nothing says anything about can't be assumed to render
|
||||
// escapes.
|
||||
EXPECT_EQ(OnTerminal({}), ColorMode::NoColor);
|
||||
EXPECT_EQ(OnTerminal({.term = ""}), ColorMode::NoColor);
|
||||
EXPECT_EQ(OnTerminal({.term = "dumb"}), ColorMode::NoColor);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, ColorFromTheEmulatorWithoutTerm) {
|
||||
// `TERM` is unset for anything not launched from a shell, but the emulator
|
||||
// sets `COLORTERM` and `TERM_PROGRAM` itself, and both are specifically
|
||||
// about color. Either one identifies the terminal on its own, at whatever
|
||||
// depth it names.
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "truecolor"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "yes"}), ColorMode::Ansi16);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "vscode"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "unknown"}), ColorMode::Ansi16);
|
||||
|
||||
// An empty value says nothing at all.
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "", .term_program = ""}),
|
||||
ColorMode::NoColor);
|
||||
|
||||
// `dumb` outranks them: it states outright that escapes won't render.
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "truecolor", .term = "dumb"}),
|
||||
ColorMode::NoColor);
|
||||
|
||||
// And none of them enable color off a terminal.
|
||||
EXPECT_EQ(OffTerminal({.colorterm = "truecolor"}), ColorMode::NoColor);
|
||||
EXPECT_EQ(OffTerminal({.term_program = "vscode"}), ColorMode::NoColor);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, ExplicitPreferenceWins) {
|
||||
ColorEnvironment forcing = {.force_color = "3", .term = "xterm-256color"};
|
||||
EXPECT_EQ(ChooseColorMode(Preference::Never, forcing, /*is_terminal=*/true),
|
||||
ColorMode::NoColor);
|
||||
|
||||
ColorEnvironment disabling = {.no_color = "1", .term = "dumb"};
|
||||
EXPECT_EQ(ChooseColorMode(Preference::Always, disabling,
|
||||
/*is_terminal=*/false),
|
||||
ColorMode::Ansi16);
|
||||
|
||||
// Forcing color on without any hint of what the terminal handles gets the
|
||||
// depth every color terminal supports.
|
||||
EXPECT_EQ(ChooseColorMode(Preference::Always, {}, /*is_terminal=*/false),
|
||||
ColorMode::Ansi16);
|
||||
EXPECT_EQ(ChooseColorMode(Preference::Always, ColorTerminal(),
|
||||
/*is_terminal=*/false),
|
||||
ColorMode::Ansi256);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, NoColor) {
|
||||
// https://no-color.org: any non-empty value disables color, whatever it is.
|
||||
EXPECT_EQ(OnTerminal({.no_color = "1", .term = "xterm-256color"}),
|
||||
ColorMode::NoColor);
|
||||
EXPECT_EQ(OnTerminal({.no_color = "0", .term = "xterm-256color"}),
|
||||
ColorMode::NoColor);
|
||||
|
||||
// Being set to the empty string carries no meaning, so it must not disable
|
||||
// color: an empty variable inherited from a wrapper script would otherwise
|
||||
// silently turn color off everywhere.
|
||||
EXPECT_EQ(OnTerminal({.no_color = "", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
|
||||
// It outranks the forcing variables.
|
||||
EXPECT_EQ(OnTerminal({.no_color = "1", .force_color = "3"}),
|
||||
ColorMode::NoColor);
|
||||
EXPECT_EQ(OnTerminal({.no_color = "1", .clicolor_force = "1"}),
|
||||
ColorMode::NoColor);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, ForceColor) {
|
||||
// Color even without a terminal, at the depth the level names.
|
||||
EXPECT_EQ(OffTerminal({.force_color = "1"}), ColorMode::Ansi16);
|
||||
EXPECT_EQ(OffTerminal({.force_color = "2"}), ColorMode::Ansi256);
|
||||
EXPECT_EQ(OffTerminal({.force_color = "3"}), ColorMode::Truecolor);
|
||||
|
||||
// The level overrides what the terminal claims.
|
||||
EXPECT_EQ(OnTerminal({.force_color = "1", .colorterm = "truecolor"}),
|
||||
ColorMode::Ansi16);
|
||||
|
||||
// Any other non-empty value enables color without naming a depth.
|
||||
EXPECT_EQ(OffTerminal({.force_color = "true"}), ColorMode::Ansi16);
|
||||
EXPECT_EQ(OffTerminal({.force_color = "true", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
|
||||
// Zero disables color outright, even on a capable terminal.
|
||||
EXPECT_EQ(OnTerminal({.force_color = "0", .term = "xterm-256color"}),
|
||||
ColorMode::NoColor);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, EmptyValuesCarryNoOpinion) {
|
||||
// An empty variable means the same as an unset one throughout, so a wrapper
|
||||
// script that exports one without a value changes nothing.
|
||||
EXPECT_EQ(OffTerminal({.force_color = ""}), ColorMode::NoColor);
|
||||
EXPECT_EQ(OffTerminal({.clicolor_force = ""}), ColorMode::NoColor);
|
||||
EXPECT_EQ(OnTerminal({.no_color = "", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
EXPECT_EQ(OnTerminal({.clicolor = "", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
EXPECT_EQ(OnTerminal({.force_color = "", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, CliColor) {
|
||||
// The BSD convention: `CLICOLOR_FORCE` enables color off a terminal, and
|
||||
// `CLICOLOR=0` disables it on one.
|
||||
EXPECT_EQ(OffTerminal({.clicolor_force = "1"}), ColorMode::Ansi16);
|
||||
EXPECT_EQ(OffTerminal({.clicolor_force = "1", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
// `0` means "don't force", not "disable", so a terminal still gets color.
|
||||
EXPECT_EQ(OnTerminal({.clicolor_force = "0", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
EXPECT_EQ(OffTerminal({.clicolor_force = "0"}), ColorMode::NoColor);
|
||||
|
||||
EXPECT_EQ(OnTerminal({.clicolor = "0", .term = "xterm-256color"}),
|
||||
ColorMode::NoColor);
|
||||
EXPECT_EQ(OnTerminal({.clicolor = "1", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
|
||||
// Forcing beats disabling.
|
||||
EXPECT_EQ(OnTerminal({.clicolor_force = "1", .clicolor = "0"}),
|
||||
ColorMode::Ansi16);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, ColorDepthFromColorterm) {
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "truecolor", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "24bit", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.colorterm = "", .term = "xterm"}), ColorMode::Ansi16);
|
||||
|
||||
// `COLORTERM` can't enable color off a terminal, so a rich value inherited
|
||||
// by a redirected stream can't smuggle escapes into it.
|
||||
EXPECT_EQ(OffTerminal({.colorterm = "truecolor", .term = "xterm"}),
|
||||
ColorMode::NoColor);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, ColorDepthFromTermProgram) {
|
||||
EXPECT_EQ(OnTerminal({.term_program = "vscode", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "iTerm.app", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "WarpTerminal", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "Hyper", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "Tabby", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "Terminus", .term = "xterm"}),
|
||||
ColorMode::Truecolor);
|
||||
// Apple's Terminal renders only the 256-color palette.
|
||||
EXPECT_EQ(OnTerminal({.term_program = "Apple_Terminal", .term = "xterm"}),
|
||||
ColorMode::Ansi256);
|
||||
EXPECT_EQ(OnTerminal({.term_program = "unknown", .term = "xterm-256color"}),
|
||||
ColorMode::Ansi256);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, ColorDepthFromTerm) {
|
||||
EXPECT_EQ(OnTerminal({.term = "xterm-kitty"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "alacritty"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "wezterm"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "foot-extra"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "xterm-direct"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "ghostty"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "contour-latest"}), ColorMode::Truecolor);
|
||||
EXPECT_EQ(OnTerminal({.term = "xterm-truecolor"}), ColorMode::Truecolor);
|
||||
|
||||
EXPECT_EQ(OnTerminal({.term = "xterm-256color"}), ColorMode::Ansi256);
|
||||
EXPECT_EQ(OnTerminal({.term = "screen-256color"}), ColorMode::Ansi256);
|
||||
EXPECT_EQ(OnTerminal({.term = "putty-256"}), ColorMode::Ansi256);
|
||||
|
||||
// A terminal matching both a truecolor and a 256-color pattern takes the
|
||||
// richer one, so the order these are tried in is load-bearing.
|
||||
EXPECT_EQ(OnTerminal({.term = "vte-256color"}), ColorMode::Truecolor);
|
||||
|
||||
// Known to render color, but with nothing saying how much.
|
||||
EXPECT_EQ(OnTerminal({.term = "xterm"}), ColorMode::Ansi16);
|
||||
EXPECT_EQ(OnTerminal({.term = "linux"}), ColorMode::Ansi16);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, Charset) {
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, "en_US.UTF-8"), Charset::Utf8);
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, "C.utf8"), Charset::Utf8);
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, "en_US.utf-8"), Charset::Utf8);
|
||||
|
||||
// Drawing box characters into a terminal decoding something else turns them
|
||||
// into several bytes of mojibake and destroys the alignment they were for.
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, "C"), Charset::Ascii);
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, "POSIX"), Charset::Ascii);
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, "en_US.ISO-8859-1"),
|
||||
Charset::Ascii);
|
||||
EXPECT_EQ(ChooseCharset(Preference::Auto, ""), Charset::Ascii);
|
||||
|
||||
EXPECT_EQ(ChooseCharset(Preference::Never, "en_US.UTF-8"), Charset::Ascii);
|
||||
EXPECT_EQ(ChooseCharset(Preference::Always, "C"), Charset::Utf8);
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, Defaults) {
|
||||
// The defaults describe a plain-text sink, which is what a file or a pipe
|
||||
// gets and what tests should use unless exercising something richer.
|
||||
Capabilities capabilities;
|
||||
EXPECT_EQ(capabilities.color_mode, ColorMode::NoColor);
|
||||
EXPECT_EQ(capabilities.charset, Charset::Ascii);
|
||||
EXPECT_FALSE(capabilities.is_terminal);
|
||||
EXPECT_FALSE(capabilities.columns.has_value());
|
||||
}
|
||||
|
||||
TEST(CapabilitiesTest, Detect) {
|
||||
// Detection reads the process environment and the descriptor it is handed, so
|
||||
// only what neither can change is pinned here. What the policy decides from
|
||||
// given inputs is tested above, against `ChooseColorMode` and `ChooseCharset`
|
||||
// directly.
|
||||
//
|
||||
// It detects against a file rather than the process's own streams: those are
|
||||
// a pipe under the test runner but a terminal under a debugger, and an
|
||||
// exported `FORCE_COLOR` turns color on for either.
|
||||
auto dir = Filesystem::MakeTmpDir();
|
||||
ASSERT_TRUE(dir.ok()) << dir.error();
|
||||
auto file = dir->OpenWriteOnly("out", Filesystem::CreationOptions::CreateNew);
|
||||
ASSERT_TRUE(file.ok()) << file.error();
|
||||
|
||||
Capabilities capabilities = Capabilities::Detect(*file);
|
||||
// A file is never a terminal.
|
||||
EXPECT_FALSE(capabilities.is_terminal);
|
||||
// `COLUMNS` reaches detection from the environment, so whether a width is
|
||||
// found depends on it, but one that is found is usable.
|
||||
if (capabilities.columns) {
|
||||
EXPECT_GT(*capabilities.columns, 0);
|
||||
}
|
||||
EXPECT_GT(capabilities.tab_width, 0);
|
||||
|
||||
// A preference decides on its own, whatever the environment holds. Color
|
||||
// forced on picks a depth from the environment, so only that it is on can be
|
||||
// pinned here.
|
||||
EXPECT_EQ(Capabilities::Detect(
|
||||
*file, {.color = Preference::Never, .utf8 = Preference::Never})
|
||||
.color_mode,
|
||||
ColorMode::NoColor);
|
||||
EXPECT_NE(
|
||||
Capabilities::Detect(*file, {.color = Preference::Always}).color_mode,
|
||||
ColorMode::NoColor);
|
||||
EXPECT_EQ(Capabilities::Detect(*file, {.utf8 = Preference::Always}).charset,
|
||||
Charset::Utf8);
|
||||
EXPECT_EQ(Capabilities::Detect(*file, {.utf8 = Preference::Never}).charset,
|
||||
Charset::Ascii);
|
||||
|
||||
(*std::move(file)).Close().Check();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace Carbon::Terminal
|
||||
Reference in New Issue
Block a user