Files
carbon-lang/common/terminal/capabilities.cpp
T
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

217 lines
7.5 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/capabilities.h"
#include <sys/ioctl.h>
#include <unistd.h>
#include <cstdlib>
#include "llvm/ADT/StringExtras.h"
#include "llvm/ADT/StringSwitch.h"
namespace Carbon::Terminal {
// Returns the value of `name` in the process environment, empty when unset.
static auto GetEnv(const char* name) -> llvm::StringRef {
const char* value = std::getenv(name);
return value ? llvm::StringRef(value) : llvm::StringRef();
}
auto ColorEnvironment::FromProcess() -> ColorEnvironment {
return {.no_color = GetEnv("NO_COLOR"),
.clicolor_force = GetEnv("CLICOLOR_FORCE"),
.force_color = GetEnv("FORCE_COLOR"),
.clicolor = GetEnv("CLICOLOR"),
.colorterm = GetEnv("COLORTERM"),
.term_program = GetEnv("TERM_PROGRAM"),
.term = GetEnv("TERM")};
}
// Returns whether the environment and the stream call for color, ignoring any
// explicit preference. See `ChooseColorMode` for the precedence this
// implements and where it comes from.
static auto EnvironmentEnablesColor(const ColorEnvironment& env,
bool is_terminal) -> bool {
if (!env.no_color.empty()) {
return false;
}
// The forcing variables use `0` to decline to force, and `FORCE_COLOR` takes
// it further as a request to disable.
if (env.force_color == "0") {
return false;
}
if (!env.force_color.empty() ||
(!env.clicolor_force.empty() && env.clicolor_force != "0")) {
return true;
}
if (env.clicolor == "0") {
return false;
}
if (!is_terminal) {
return false;
}
// `dumb` says outright that escape sequences won't render, which outranks
// anything below claiming they will.
if (env.term == "dumb") {
return false;
}
// Any of these identifies the terminal as something that renders escapes. A
// terminal that none of them describe can't be assumed to.
//
// `COLORTERM` and `TERM_PROGRAM` stand on their own rather than refining
// `TERM`: `TERM` names a terminfo entry, while these name the emulator and
// the color it handles.
return !env.term.empty() || !env.colorterm.empty() ||
!env.term_program.empty();
}
// Returns the richest color escapes the terminal is believed to accept.
//
// Every signal here is a heuristic: there is no way to ask a terminal what it
// supports without writing to it and parsing a reply, which would be far too
// invasive for a compiler. Guessing too high garbles color on a terminal that
// can't keep up, and guessing too low only makes output plainer, so unknown
// terminals get the conservative answer.
static auto DetectColorDepth(const ColorEnvironment& env) -> ColorMode {
// `FORCE_COLOR`'s levels name a depth outright.
if (auto mode = llvm::StringSwitch<std::optional<ColorMode>>(env.force_color)
.Case("1", ColorMode::Ansi16)
.Case("2", ColorMode::Ansi256)
.Case("3", ColorMode::Truecolor)
.Default(std::nullopt)) {
return *mode;
}
// The convention documented at
// https://github.com/termstandard/colors#checking-for-colorterm.
if (env.colorterm == "truecolor" || env.colorterm == "24bit") {
return ColorMode::Truecolor;
}
// `TERM_PROGRAM` identifies the emulator regardless of how `TERM` is set,
// which matters because several of these ship a conservative `TERM` while
// rendering far more than it claims.
{
if (auto mode =
llvm::StringSwitch<std::optional<ColorMode>>(env.term_program)
.Case("vscode", ColorMode::Truecolor)
.Case("iTerm.app", ColorMode::Truecolor)
.Case("WarpTerminal", ColorMode::Truecolor)
.Case("Hyper", ColorMode::Truecolor)
.Case("Tabby", ColorMode::Truecolor)
.Case("Terminus", ColorMode::Truecolor)
// Apple's Terminal renders only the 256-color palette.
.Case("Apple_Terminal", ColorMode::Ansi256)
.Default(std::nullopt)) {
return *mode;
}
}
// The enumerated terminals that stand in for a terminfo lookup.
{
if (auto mode = llvm::StringSwitch<std::optional<ColorMode>>(env.term)
.Case("xterm-kitty", ColorMode::Truecolor)
.Case("alacritty", ColorMode::Truecolor)
.Case("wezterm", ColorMode::Truecolor)
.Case("ghostty", ColorMode::Truecolor)
.StartsWith("foot", ColorMode::Truecolor)
.StartsWith("contour", ColorMode::Truecolor)
.StartsWith("vte", ColorMode::Truecolor)
.EndsWith("-direct", ColorMode::Truecolor)
.EndsWith("-truecolor", ColorMode::Truecolor)
.EndsWith("-256color", ColorMode::Ansi256)
.EndsWith("-256", ColorMode::Ansi256)
.Default(std::nullopt)) {
return *mode;
}
}
// Color is called for, but nothing said how much of it works.
return ColorMode::Ansi16;
}
auto ChooseColorMode(Preference preference, const ColorEnvironment& env,
bool is_terminal) -> ColorMode {
switch (preference) {
case Preference::Never:
return ColorMode::NoColor;
case Preference::Always:
break;
case Preference::Auto:
if (!EnvironmentEnablesColor(env, is_terminal)) {
return ColorMode::NoColor;
}
break;
}
return DetectColorDepth(env);
}
auto ChooseCharset(Preference preference, llvm::StringRef locale) -> Charset {
switch (preference) {
case Preference::Never:
return Charset::Ascii;
case Preference::Always:
return Charset::Utf8;
case Preference::Auto:
break;
}
// Locale names spell the encoding several ways: `en_US.UTF-8`, `C.utf8`, and
// bare `UTF-8` all appear in the wild.
return locale.contains_insensitive("utf-8") ||
locale.contains_insensitive("utf8")
? Charset::Utf8
: Charset::Ascii;
}
// Returns the locale that determines the terminal's character encoding,
// following the precedence POSIX defines for `LC_CTYPE`.
static auto GetLocale() -> llvm::StringRef {
for (const char* name : {"LC_ALL", "LC_CTYPE", "LANG"}) {
if (llvm::StringRef value = GetEnv(name); !value.empty()) {
return value;
}
}
return "";
}
// Returns the terminal's width in columns, or nullopt when there is nothing to
// ask.
//
// `COLUMNS` comes first: when it is exported, the user has deliberately
// overridden the real width.
static auto GetColumns(int fd) -> std::optional<int> {
int columns = 0;
if (llvm::to_integer(GetEnv("COLUMNS"), columns) && columns > 0) {
return columns;
}
struct winsize size = {};
if (ioctl(fd, TIOCGWINSZ, &size) == 0 && size.ws_col > 0) {
return size.ws_col;
}
return std::nullopt;
}
auto Capabilities::Detect(Filesystem::WriteFileRef file,
Preferences preferences) -> Capabilities {
int fd = file.unix_fd();
Capabilities capabilities;
capabilities.is_terminal = isatty(fd) != 0;
capabilities.color_mode =
ChooseColorMode(preferences.color, ColorEnvironment::FromProcess(),
capabilities.is_terminal);
capabilities.charset = ChooseCharset(preferences.utf8, GetLocale());
capabilities.columns = GetColumns(fd);
return capabilities;
}
} // namespace Carbon::Terminal