mirror of
https://github.com/carbon-language/carbon-lang.git
synced 2026-09-24 22:02:23 +01:00
Everything drawn into a buffer was checked against `columns()`. That is
right for wrapping and for line drawing, both of which have somewhere
else to put what doesn't fit, but wrong for `DrawText`, which exists for
text that must not be broken and sometimes has to run past the width
with no other answer available. It and `DrawCodePoint` now check only
that the column is non-negative and the row is one a grid can index, and
widen the buffer as far as the text needs; `DrawWrappedText`,
`DrawHorizontalLine`, `DrawVerticalLine`, and `DrawBox` are unchanged.
That also settles what a caller does after a drawing overhangs, since
`DrawEnd` exists so that a run can continue where the last one ended,
and that continuation was itself a checked error whenever the previous
run overhung. A column computed to be negative, such as a gutter
narrower than the line number it holds, still fails.
A color picked to read against black is hard to read against white, and
nothing in `Capabilities` said which a stream was going into.
`ChooseBackground` reads `COLORFGBG`, which `rxvt` and its derivatives
set to the foreground and background palette indices, and takes anything
it doesn't answer to be dark: guessing dark costs contrast, while
guessing light puts pale text on a pale background. Asking the terminal
itself with an `OSC 11` query is the accurate answer, and needs raw
mode, a timeout, and somewhere to put the reply, so there is a TODO for
it rather than an implementation.
Every corner, tee, and crossing came out of `Charset::Ascii` as `+`,
which left six of the shapes a diagnostic draws indistinguishable: the
rule closing a frame read as the one separating two snippets, and the
anchor opening a diagnostic as the one carrying it on. Each stand-in now
keeps the axis its line runs through, which leaves `+` meaning a
crossing and nothing else. A tee keeps its through-stroke and leaves the
branch to what is drawn beside it, and a corner is `.` where its line
leaves downward and `'` where it arrives from above, which is where
those characters sit in their cells. A box is a box again:
```
+--+ .--.
| | -> | |
+--+ '--'
```
Assisted-by: Claude Code
341 lines
15 KiB
C++
341 lines
15 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 <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, BackgroundFromColorFgBg) {
|
|
// `fg;bg`, which is what `rxvt` and its derivatives write.
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;0"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;15"),
|
|
Background::Light);
|
|
// The eighth entry is white and the ninth the dark gray after it, so the
|
|
// halves are not simply the low and high eight.
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;7"),
|
|
Background::Light);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;8"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;6"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;9"),
|
|
Background::Light);
|
|
// Some terminals write a third field between the two.
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;default;0"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;default;15"),
|
|
Background::Light);
|
|
}
|
|
|
|
TEST(CapabilitiesTest, BackgroundWithNothingToGoOn) {
|
|
// Unset, unparsable, and out of range all say nothing, and what nothing
|
|
// gets is the assumption that costs contrast rather than legibility.
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, ""), Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "default;default"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "0;99"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Auto, "15;"),
|
|
Background::Dark);
|
|
}
|
|
|
|
TEST(CapabilitiesTest, BackgroundPreferenceWins) {
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Dark, "0;15"),
|
|
Background::Dark);
|
|
EXPECT_EQ(ChooseBackground(BackgroundPreference::Light, "15;0"),
|
|
Background::Light);
|
|
}
|
|
|
|
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_EQ(capabilities.background, Background::Dark);
|
|
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);
|
|
EXPECT_EQ(
|
|
Capabilities::Detect(*file, {.background = BackgroundPreference::Light})
|
|
.background,
|
|
Background::Light);
|
|
EXPECT_EQ(
|
|
Capabilities::Detect(*file, {.background = BackgroundPreference::Dark})
|
|
.background,
|
|
Background::Dark);
|
|
|
|
(*std::move(file)).Close().Check();
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace Carbon::Terminal
|