Files
Chandler CarruthandRichard Smith 9c486de2de 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>
2026-08-19 21:54:46 +00:00

172 lines
6.9 KiB
C++

// 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/color.h"
#include <gtest/gtest.h>
#include "llvm/ADT/SmallString.h"
#include "llvm/ADT/StringExtras.h"
namespace Carbon::Terminal {
namespace {
auto Escape(Color color, ColorMode mode,
ColorTarget target = ColorTarget::Foreground) -> std::string {
llvm::SmallString<32> escape;
color.AppendEscape(escape, mode, target);
return std::string(escape);
}
TEST(ColorTest, AnsiEscapes) {
// Named colors use their own SGR codes rather than the extended forms, in
// every mode that has color at all, so the terminal renders them from the
// user's palette.
for (ColorMode mode :
{ColorMode::Ansi16, ColorMode::Ansi256, ColorMode::Truecolor}) {
EXPECT_EQ(Escape(AnsiColor::Red, mode), "\x1b[31m");
EXPECT_EQ(Escape(AnsiColor::Black, mode), "\x1b[30m");
EXPECT_EQ(Escape(AnsiColor::BrightCyan, mode), "\x1b[96m");
EXPECT_EQ(Escape(AnsiColor::Red, mode, ColorTarget::Background),
"\x1b[41m");
EXPECT_EQ(Escape(AnsiColor::BrightWhite, mode, ColorTarget::Background),
"\x1b[107m");
}
EXPECT_EQ(Escape(AnsiColor::Red, ColorMode::NoColor), "");
}
TEST(ColorTest, RgbEscapes) {
Color red(255, 0, 0);
EXPECT_EQ(Escape(red, ColorMode::Truecolor), "\x1b[38;2;255;0;0m");
EXPECT_EQ(Escape(red, ColorMode::Truecolor, ColorTarget::Background),
"\x1b[48;2;255;0;0m");
EXPECT_EQ(Escape(red, ColorMode::Ansi256), "\x1b[38;5;196m");
EXPECT_EQ(Escape(red, ColorMode::Ansi16), "\x1b[91m");
EXPECT_EQ(Escape(red, ColorMode::NoColor), "");
}
TEST(ColorTest, UnderlineEscapes) {
// Underline colors only exist in the extended-color escapes, so in `Ansi16`
// the terminal draws the underline in the foreground color.
EXPECT_EQ(
Escape(AnsiColor::Red, ColorMode::Truecolor, ColorTarget::Underline),
"\x1b[58;5;1m");
EXPECT_EQ(Escape(AnsiColor::Red, ColorMode::Ansi256, ColorTarget::Underline),
"\x1b[58;5;1m");
EXPECT_EQ(Escape(AnsiColor::Red, ColorMode::Ansi16, ColorTarget::Underline),
"");
Color green(0, 255, 0);
EXPECT_EQ(Escape(green, ColorMode::Truecolor, ColorTarget::Underline),
"\x1b[58;2;0;255;0m");
EXPECT_EQ(Escape(green, ColorMode::Ansi256, ColorTarget::Underline),
"\x1b[58;5;46m");
EXPECT_EQ(Escape(green, ColorMode::Ansi16, ColorTarget::Underline), "");
}
TEST(ColorTest, DownsampleToAnsi16) {
// The reference value of each ANSI color must come back as that color, or
// downsampling would shift colors that were already expressible. Spelling
// the values out here rather than reading them back from the same table the
// implementation uses is what makes this catch a wrong table.
struct Expected {
Color color;
llvm::StringRef escape;
};
Expected cases[] = {
{Color(0, 0, 0), "\x1b[30m"}, {Color(205, 0, 0), "\x1b[31m"},
{Color(0, 205, 0), "\x1b[32m"}, {Color(205, 205, 0), "\x1b[33m"},
{Color(0, 0, 238), "\x1b[34m"}, {Color(205, 0, 205), "\x1b[35m"},
{Color(0, 205, 205), "\x1b[36m"}, {Color(229, 229, 229), "\x1b[37m"},
{Color(127, 127, 127), "\x1b[90m"}, {Color(255, 0, 0), "\x1b[91m"},
{Color(0, 255, 0), "\x1b[92m"}, {Color(255, 255, 0), "\x1b[93m"},
{Color(92, 92, 255), "\x1b[94m"}, {Color(255, 0, 255), "\x1b[95m"},
{Color(0, 255, 255), "\x1b[96m"}, {Color(255, 255, 255), "\x1b[97m"},
};
for (const Expected& expected : cases) {
EXPECT_EQ(Escape(expected.color, ColorMode::Ansi16), expected.escape)
<< expected.color;
}
// Colors between the reference values land on the nearest one.
EXPECT_EQ(Escape(Color(250, 10, 10), ColorMode::Ansi16), "\x1b[91m");
EXPECT_EQ(Escape(Color(10, 10, 10), ColorMode::Ansi16), "\x1b[30m");
EXPECT_EQ(Escape(Color(120, 120, 120), ColorMode::Ansi16), "\x1b[90m");
}
TEST(ColorTest, DownsampleToPalette) {
// The corners of the 6x6x6 cube are exactly representable.
EXPECT_EQ(Escape(Color(0, 0, 0), ColorMode::Ansi256), "\x1b[38;5;16m");
EXPECT_EQ(Escape(Color(255, 255, 255), ColorMode::Ansi256), "\x1b[38;5;231m");
EXPECT_EQ(Escape(Color(255, 0, 0), ColorMode::Ansi256), "\x1b[38;5;196m");
EXPECT_EQ(Escape(Color(0, 0, 255), ColorMode::Ansi256), "\x1b[38;5;21m");
// The cube's levels are unevenly spaced, so rounding has to account for that
// rather than divide: 95 and 135 are adjacent levels only 40 apart.
EXPECT_EQ(Escape(Color(95, 0, 0), ColorMode::Ansi256), "\x1b[38;5;52m");
EXPECT_EQ(Escape(Color(130, 0, 0), ColorMode::Ansi256), "\x1b[38;5;88m");
// Near-neutral colors land on the gray ramp, which is far finer than the
// cube's diagonal, except at the ends where the cube wins.
EXPECT_EQ(Escape(Color(8, 8, 8), ColorMode::Ansi256), "\x1b[38;5;232m");
EXPECT_EQ(Escape(Color(128, 128, 128), ColorMode::Ansi256), "\x1b[38;5;244m");
EXPECT_EQ(Escape(Color(238, 238, 238), ColorMode::Ansi256), "\x1b[38;5;255m");
}
TEST(ColorTest, DownsampleAvoidsPaletteEntries) {
// Indices 0 through 15 render from the user's palette, so an exact RGB
// request must never be answered with one.
for (int r = 0; r < 256; r += 17) {
for (int g = 0; g < 256; g += 17) {
for (int b = 0; b < 256; b += 17) {
Color color(r, g, b);
std::string escape = Escape(color, ColorMode::Ansi256);
int index = 0;
ASSERT_TRUE(llvm::to_integer(
llvm::StringRef(escape).drop_front(7).drop_back(1), index))
<< color;
EXPECT_GE(index, 16) << color;
}
}
}
}
TEST(ColorTest, Equality) {
EXPECT_EQ(Color(AnsiColor::Red), Color(AnsiColor::Red));
EXPECT_NE(Color(AnsiColor::Red), Color(AnsiColor::Blue));
EXPECT_EQ(Color(1, 2, 3), Color(1, 2, 3));
EXPECT_NE(Color(1, 2, 3), Color(1, 2, 4));
// A named color and its reference value are different colors: the terminal
// renders one from the palette and the other exactly.
EXPECT_NE(Color(AnsiColor::BrightRed), Color(255, 0, 0));
// A palette index occupies the same byte as the red channel, so these pairs
// hold identical channel bytes and are told apart only by their kind.
EXPECT_NE(Color(AnsiColor::Red), Color(1, 0, 0));
EXPECT_NE(Color(AnsiColor::Black), Color(0, 0, 0));
EXPECT_NE(Color(AnsiColor::Black), Color());
EXPECT_NE(Color(0, 0, 0), Color());
EXPECT_EQ(Color(), Color());
}
TEST(ColorTest, Unset) {
EXPECT_FALSE(Color().is_set());
EXPECT_EQ(Color().kind(), Color::Kind::None);
// Black is a color like any other, however little of it there is.
EXPECT_TRUE(Color(AnsiColor::Black).is_set());
EXPECT_TRUE(Color(0, 0, 0).is_set());
}
TEST(ColorTest, Print) {
EXPECT_EQ(PrintToString(Color(AnsiColor::BrightMagenta)), "BrightMagenta");
EXPECT_EQ(PrintToString(Color(0x12, 0xab, 0xff)), "#12abff");
EXPECT_EQ(PrintToString(Color()), "None");
}
} // namespace
} // namespace Carbon::Terminal