Spaces:
Sleeping
Sleeping
""" | |
Output for vt100 terminals. | |
A lot of thanks, regarding outputting of colors, goes to the Pygments project: | |
(We don't rely on Pygments anymore, because many things are very custom, and | |
everything has been highly optimized.) | |
http://pygments.org/ | |
""" | |
from __future__ import annotations | |
import io | |
import os | |
import sys | |
from typing import Callable, Dict, Hashable, Iterable, Sequence, TextIO, Tuple | |
from prompt_toolkit.cursor_shapes import CursorShape | |
from prompt_toolkit.data_structures import Size | |
from prompt_toolkit.output import Output | |
from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs | |
from prompt_toolkit.utils import is_dumb_terminal | |
from .color_depth import ColorDepth | |
from .flush_stdout import flush_stdout | |
__all__ = [ | |
"Vt100_Output", | |
] | |
FG_ANSI_COLORS = { | |
"ansidefault": 39, | |
# Low intensity. | |
"ansiblack": 30, | |
"ansired": 31, | |
"ansigreen": 32, | |
"ansiyellow": 33, | |
"ansiblue": 34, | |
"ansimagenta": 35, | |
"ansicyan": 36, | |
"ansigray": 37, | |
# High intensity. | |
"ansibrightblack": 90, | |
"ansibrightred": 91, | |
"ansibrightgreen": 92, | |
"ansibrightyellow": 93, | |
"ansibrightblue": 94, | |
"ansibrightmagenta": 95, | |
"ansibrightcyan": 96, | |
"ansiwhite": 97, | |
} | |
BG_ANSI_COLORS = { | |
"ansidefault": 49, | |
# Low intensity. | |
"ansiblack": 40, | |
"ansired": 41, | |
"ansigreen": 42, | |
"ansiyellow": 43, | |
"ansiblue": 44, | |
"ansimagenta": 45, | |
"ansicyan": 46, | |
"ansigray": 47, | |
# High intensity. | |
"ansibrightblack": 100, | |
"ansibrightred": 101, | |
"ansibrightgreen": 102, | |
"ansibrightyellow": 103, | |
"ansibrightblue": 104, | |
"ansibrightmagenta": 105, | |
"ansibrightcyan": 106, | |
"ansiwhite": 107, | |
} | |
ANSI_COLORS_TO_RGB = { | |
"ansidefault": ( | |
0x00, | |
0x00, | |
0x00, | |
), # Don't use, 'default' doesn't really have a value. | |
"ansiblack": (0x00, 0x00, 0x00), | |
"ansigray": (0xE5, 0xE5, 0xE5), | |
"ansibrightblack": (0x7F, 0x7F, 0x7F), | |
"ansiwhite": (0xFF, 0xFF, 0xFF), | |
# Low intensity. | |
"ansired": (0xCD, 0x00, 0x00), | |
"ansigreen": (0x00, 0xCD, 0x00), | |
"ansiyellow": (0xCD, 0xCD, 0x00), | |
"ansiblue": (0x00, 0x00, 0xCD), | |
"ansimagenta": (0xCD, 0x00, 0xCD), | |
"ansicyan": (0x00, 0xCD, 0xCD), | |
# High intensity. | |
"ansibrightred": (0xFF, 0x00, 0x00), | |
"ansibrightgreen": (0x00, 0xFF, 0x00), | |
"ansibrightyellow": (0xFF, 0xFF, 0x00), | |
"ansibrightblue": (0x00, 0x00, 0xFF), | |
"ansibrightmagenta": (0xFF, 0x00, 0xFF), | |
"ansibrightcyan": (0x00, 0xFF, 0xFF), | |
} | |
assert set(FG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) | |
assert set(BG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) | |
assert set(ANSI_COLORS_TO_RGB) == set(ANSI_COLOR_NAMES) | |
def _get_closest_ansi_color(r: int, g: int, b: int, exclude: Sequence[str] = ()) -> str: | |
""" | |
Find closest ANSI color. Return it by name. | |
:param r: Red (Between 0 and 255.) | |
:param g: Green (Between 0 and 255.) | |
:param b: Blue (Between 0 and 255.) | |
:param exclude: A tuple of color names to exclude. (E.g. ``('ansired', )``.) | |
""" | |
exclude = list(exclude) | |
# When we have a bit of saturation, avoid the gray-like colors, otherwise, | |
# too often the distance to the gray color is less. | |
saturation = abs(r - g) + abs(g - b) + abs(b - r) # Between 0..510 | |
if saturation > 30: | |
exclude.extend(["ansilightgray", "ansidarkgray", "ansiwhite", "ansiblack"]) | |
# Take the closest color. | |
# (Thanks to Pygments for this part.) | |
distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff) | |
match = "ansidefault" | |
for name, (r2, g2, b2) in ANSI_COLORS_TO_RGB.items(): | |
if name != "ansidefault" and name not in exclude: | |
d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2 | |
if d < distance: | |
match = name | |
distance = d | |
return match | |
_ColorCodeAndName = Tuple[int, str] | |
class _16ColorCache: | |
""" | |
Cache which maps (r, g, b) tuples to 16 ansi colors. | |
:param bg: Cache for background colors, instead of foreground. | |
""" | |
def __init__(self, bg: bool = False) -> None: | |
self.bg = bg | |
self._cache: dict[Hashable, _ColorCodeAndName] = {} | |
def get_code( | |
self, value: tuple[int, int, int], exclude: Sequence[str] = () | |
) -> _ColorCodeAndName: | |
""" | |
Return a (ansi_code, ansi_name) tuple. (E.g. ``(44, 'ansiblue')``.) for | |
a given (r,g,b) value. | |
""" | |
key: Hashable = (value, tuple(exclude)) | |
cache = self._cache | |
if key not in cache: | |
cache[key] = self._get(value, exclude) | |
return cache[key] | |
def _get( | |
self, value: tuple[int, int, int], exclude: Sequence[str] = () | |
) -> _ColorCodeAndName: | |
r, g, b = value | |
match = _get_closest_ansi_color(r, g, b, exclude=exclude) | |
# Turn color name into code. | |
if self.bg: | |
code = BG_ANSI_COLORS[match] | |
else: | |
code = FG_ANSI_COLORS[match] | |
return code, match | |
class _256ColorCache(Dict[Tuple[int, int, int], int]): | |
""" | |
Cache which maps (r, g, b) tuples to 256 colors. | |
""" | |
def __init__(self) -> None: | |
# Build color table. | |
colors: list[tuple[int, int, int]] = [] | |
# colors 0..15: 16 basic colors | |
colors.append((0x00, 0x00, 0x00)) # 0 | |
colors.append((0xCD, 0x00, 0x00)) # 1 | |
colors.append((0x00, 0xCD, 0x00)) # 2 | |
colors.append((0xCD, 0xCD, 0x00)) # 3 | |
colors.append((0x00, 0x00, 0xEE)) # 4 | |
colors.append((0xCD, 0x00, 0xCD)) # 5 | |
colors.append((0x00, 0xCD, 0xCD)) # 6 | |
colors.append((0xE5, 0xE5, 0xE5)) # 7 | |
colors.append((0x7F, 0x7F, 0x7F)) # 8 | |
colors.append((0xFF, 0x00, 0x00)) # 9 | |
colors.append((0x00, 0xFF, 0x00)) # 10 | |
colors.append((0xFF, 0xFF, 0x00)) # 11 | |
colors.append((0x5C, 0x5C, 0xFF)) # 12 | |
colors.append((0xFF, 0x00, 0xFF)) # 13 | |
colors.append((0x00, 0xFF, 0xFF)) # 14 | |
colors.append((0xFF, 0xFF, 0xFF)) # 15 | |
# colors 16..232: the 6x6x6 color cube | |
valuerange = (0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF) | |
for i in range(217): | |
r = valuerange[(i // 36) % 6] | |
g = valuerange[(i // 6) % 6] | |
b = valuerange[i % 6] | |
colors.append((r, g, b)) | |
# colors 233..253: grayscale | |
for i in range(1, 22): | |
v = 8 + i * 10 | |
colors.append((v, v, v)) | |
self.colors = colors | |
def __missing__(self, value: tuple[int, int, int]) -> int: | |
r, g, b = value | |
# Find closest color. | |
# (Thanks to Pygments for this!) | |
distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff) | |
match = 0 | |
for i, (r2, g2, b2) in enumerate(self.colors): | |
if i >= 16: # XXX: We ignore the 16 ANSI colors when mapping RGB | |
# to the 256 colors, because these highly depend on | |
# the color scheme of the terminal. | |
d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2 | |
if d < distance: | |
match = i | |
distance = d | |
# Turn color name into code. | |
self[value] = match | |
return match | |
_16_fg_colors = _16ColorCache(bg=False) | |
_16_bg_colors = _16ColorCache(bg=True) | |
_256_colors = _256ColorCache() | |
class _EscapeCodeCache(Dict[Attrs, str]): | |
""" | |
Cache for VT100 escape codes. It maps | |
(fgcolor, bgcolor, bold, underline, strike, reverse) tuples to VT100 | |
escape sequences. | |
:param true_color: When True, use 24bit colors instead of 256 colors. | |
""" | |
def __init__(self, color_depth: ColorDepth) -> None: | |
self.color_depth = color_depth | |
def __missing__(self, attrs: Attrs) -> str: | |
( | |
fgcolor, | |
bgcolor, | |
bold, | |
underline, | |
strike, | |
italic, | |
blink, | |
reverse, | |
hidden, | |
) = attrs | |
parts: list[str] = [] | |
parts.extend(self._colors_to_code(fgcolor or "", bgcolor or "")) | |
if bold: | |
parts.append("1") | |
if italic: | |
parts.append("3") | |
if blink: | |
parts.append("5") | |
if underline: | |
parts.append("4") | |
if reverse: | |
parts.append("7") | |
if hidden: | |
parts.append("8") | |
if strike: | |
parts.append("9") | |
if parts: | |
result = "\x1b[0;" + ";".join(parts) + "m" | |
else: | |
result = "\x1b[0m" | |
self[attrs] = result | |
return result | |
def _color_name_to_rgb(self, color: str) -> tuple[int, int, int]: | |
"Turn 'ffffff', into (0xff, 0xff, 0xff)." | |
try: | |
rgb = int(color, 16) | |
except ValueError: | |
raise | |
else: | |
r = (rgb >> 16) & 0xFF | |
g = (rgb >> 8) & 0xFF | |
b = rgb & 0xFF | |
return r, g, b | |
def _colors_to_code(self, fg_color: str, bg_color: str) -> Iterable[str]: | |
""" | |
Return a tuple with the vt100 values that represent this color. | |
""" | |
# When requesting ANSI colors only, and both fg/bg color were converted | |
# to ANSI, ensure that the foreground and background color are not the | |
# same. (Unless they were explicitly defined to be the same color.) | |
fg_ansi = "" | |
def get(color: str, bg: bool) -> list[int]: | |
nonlocal fg_ansi | |
table = BG_ANSI_COLORS if bg else FG_ANSI_COLORS | |
if not color or self.color_depth == ColorDepth.DEPTH_1_BIT: | |
return [] | |
# 16 ANSI colors. (Given by name.) | |
elif color in table: | |
return [table[color]] | |
# RGB colors. (Defined as 'ffffff'.) | |
else: | |
try: | |
rgb = self._color_name_to_rgb(color) | |
except ValueError: | |
return [] | |
# When only 16 colors are supported, use that. | |
if self.color_depth == ColorDepth.DEPTH_4_BIT: | |
if bg: # Background. | |
if fg_color != bg_color: | |
exclude = [fg_ansi] | |
else: | |
exclude = [] | |
code, name = _16_bg_colors.get_code(rgb, exclude=exclude) | |
return [code] | |
else: # Foreground. | |
code, name = _16_fg_colors.get_code(rgb) | |
fg_ansi = name | |
return [code] | |
# True colors. (Only when this feature is enabled.) | |
elif self.color_depth == ColorDepth.DEPTH_24_BIT: | |
r, g, b = rgb | |
return [(48 if bg else 38), 2, r, g, b] | |
# 256 RGB colors. | |
else: | |
return [(48 if bg else 38), 5, _256_colors[rgb]] | |
result: list[int] = [] | |
result.extend(get(fg_color, False)) | |
result.extend(get(bg_color, True)) | |
return map(str, result) | |
def _get_size(fileno: int) -> tuple[int, int]: | |
""" | |
Get the size of this pseudo terminal. | |
:param fileno: stdout.fileno() | |
:returns: A (rows, cols) tuple. | |
""" | |
size = os.get_terminal_size(fileno) | |
return size.lines, size.columns | |
class Vt100_Output(Output): | |
""" | |
:param get_size: A callable which returns the `Size` of the output terminal. | |
:param stdout: Any object with has a `write` and `flush` method + an 'encoding' property. | |
:param term: The terminal environment variable. (xterm, xterm-256color, linux, ...) | |
:param enable_cpr: When `True` (the default), send "cursor position | |
request" escape sequences to the output in order to detect the cursor | |
position. That way, we can properly determine how much space there is | |
available for the UI (especially for drop down menus) to render. The | |
`Renderer` will still try to figure out whether the current terminal | |
does respond to CPR escapes. When `False`, never attempt to send CPR | |
requests. | |
""" | |
# For the error messages. Only display "Output is not a terminal" once per | |
# file descriptor. | |
_fds_not_a_terminal: set[int] = set() | |
def __init__( | |
self, | |
stdout: TextIO, | |
get_size: Callable[[], Size], | |
term: str | None = None, | |
default_color_depth: ColorDepth | None = None, | |
enable_bell: bool = True, | |
enable_cpr: bool = True, | |
) -> None: | |
assert all(hasattr(stdout, a) for a in ("write", "flush")) | |
self._buffer: list[str] = [] | |
self.stdout: TextIO = stdout | |
self.default_color_depth = default_color_depth | |
self._get_size = get_size | |
self.term = term | |
self.enable_bell = enable_bell | |
self.enable_cpr = enable_cpr | |
# Cache for escape codes. | |
self._escape_code_caches: dict[ColorDepth, _EscapeCodeCache] = { | |
ColorDepth.DEPTH_1_BIT: _EscapeCodeCache(ColorDepth.DEPTH_1_BIT), | |
ColorDepth.DEPTH_4_BIT: _EscapeCodeCache(ColorDepth.DEPTH_4_BIT), | |
ColorDepth.DEPTH_8_BIT: _EscapeCodeCache(ColorDepth.DEPTH_8_BIT), | |
ColorDepth.DEPTH_24_BIT: _EscapeCodeCache(ColorDepth.DEPTH_24_BIT), | |
} | |
# Keep track of whether the cursor shape was ever changed. | |
# (We don't restore the cursor shape if it was never changed - by | |
# default, we don't change them.) | |
self._cursor_shape_changed = False | |
def from_pty( | |
cls, | |
stdout: TextIO, | |
term: str | None = None, | |
default_color_depth: ColorDepth | None = None, | |
enable_bell: bool = True, | |
) -> Vt100_Output: | |
""" | |
Create an Output class from a pseudo terminal. | |
(This will take the dimensions by reading the pseudo | |
terminal attributes.) | |
""" | |
fd: int | None | |
# Normally, this requires a real TTY device, but people instantiate | |
# this class often during unit tests as well. For convenience, we print | |
# an error message, use standard dimensions, and go on. | |
try: | |
fd = stdout.fileno() | |
except io.UnsupportedOperation: | |
fd = None | |
if not stdout.isatty() and (fd is None or fd not in cls._fds_not_a_terminal): | |
msg = "Warning: Output is not a terminal (fd=%r).\n" | |
sys.stderr.write(msg % fd) | |
sys.stderr.flush() | |
if fd is not None: | |
cls._fds_not_a_terminal.add(fd) | |
def get_size() -> Size: | |
# If terminal (incorrectly) reports its size as 0, pick a | |
# reasonable default. See | |
# https://github.com/ipython/ipython/issues/10071 | |
rows, columns = (None, None) | |
# It is possible that `stdout` is no longer a TTY device at this | |
# point. In that case we get an `OSError` in the ioctl call in | |
# `get_size`. See: | |
# https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1021 | |
try: | |
rows, columns = _get_size(stdout.fileno()) | |
except OSError: | |
pass | |
return Size(rows=rows or 24, columns=columns or 80) | |
return cls( | |
stdout, | |
get_size, | |
term=term, | |
default_color_depth=default_color_depth, | |
enable_bell=enable_bell, | |
) | |
def get_size(self) -> Size: | |
return self._get_size() | |
def fileno(self) -> int: | |
"Return file descriptor." | |
return self.stdout.fileno() | |
def encoding(self) -> str: | |
"Return encoding used for stdout." | |
return self.stdout.encoding | |
def write_raw(self, data: str) -> None: | |
""" | |
Write raw data to output. | |
""" | |
self._buffer.append(data) | |
def write(self, data: str) -> None: | |
""" | |
Write text to output. | |
(Removes vt100 escape codes. -- used for safely writing text.) | |
""" | |
self._buffer.append(data.replace("\x1b", "?")) | |
def set_title(self, title: str) -> None: | |
""" | |
Set terminal title. | |
""" | |
if self.term not in ( | |
"linux", | |
"eterm-color", | |
): # Not supported by the Linux console. | |
self.write_raw( | |
"\x1b]2;{}\x07".format(title.replace("\x1b", "").replace("\x07", "")) | |
) | |
def clear_title(self) -> None: | |
self.set_title("") | |
def erase_screen(self) -> None: | |
""" | |
Erases the screen with the background color and moves the cursor to | |
home. | |
""" | |
self.write_raw("\x1b[2J") | |
def enter_alternate_screen(self) -> None: | |
self.write_raw("\x1b[?1049h\x1b[H") | |
def quit_alternate_screen(self) -> None: | |
self.write_raw("\x1b[?1049l") | |
def enable_mouse_support(self) -> None: | |
self.write_raw("\x1b[?1000h") | |
# Enable mouse-drag support. | |
self.write_raw("\x1b[?1003h") | |
# Enable urxvt Mouse mode. (For terminals that understand this.) | |
self.write_raw("\x1b[?1015h") | |
# Also enable Xterm SGR mouse mode. (For terminals that understand this.) | |
self.write_raw("\x1b[?1006h") | |
# Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr | |
# extensions. | |
def disable_mouse_support(self) -> None: | |
self.write_raw("\x1b[?1000l") | |
self.write_raw("\x1b[?1015l") | |
self.write_raw("\x1b[?1006l") | |
self.write_raw("\x1b[?1003l") | |
def erase_end_of_line(self) -> None: | |
""" | |
Erases from the current cursor position to the end of the current line. | |
""" | |
self.write_raw("\x1b[K") | |
def erase_down(self) -> None: | |
""" | |
Erases the screen from the current line down to the bottom of the | |
screen. | |
""" | |
self.write_raw("\x1b[J") | |
def reset_attributes(self) -> None: | |
self.write_raw("\x1b[0m") | |
def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None: | |
""" | |
Create new style and output. | |
:param attrs: `Attrs` instance. | |
""" | |
# Get current depth. | |
escape_code_cache = self._escape_code_caches[color_depth] | |
# Write escape character. | |
self.write_raw(escape_code_cache[attrs]) | |
def disable_autowrap(self) -> None: | |
self.write_raw("\x1b[?7l") | |
def enable_autowrap(self) -> None: | |
self.write_raw("\x1b[?7h") | |
def enable_bracketed_paste(self) -> None: | |
self.write_raw("\x1b[?2004h") | |
def disable_bracketed_paste(self) -> None: | |
self.write_raw("\x1b[?2004l") | |
def reset_cursor_key_mode(self) -> None: | |
""" | |
For vt100 only. | |
Put the terminal in cursor mode (instead of application mode). | |
""" | |
# Put the terminal in cursor mode. (Instead of application mode.) | |
self.write_raw("\x1b[?1l") | |
def cursor_goto(self, row: int = 0, column: int = 0) -> None: | |
""" | |
Move cursor position. | |
""" | |
self.write_raw("\x1b[%i;%iH" % (row, column)) | |
def cursor_up(self, amount: int) -> None: | |
if amount == 0: | |
pass | |
elif amount == 1: | |
self.write_raw("\x1b[A") | |
else: | |
self.write_raw("\x1b[%iA" % amount) | |
def cursor_down(self, amount: int) -> None: | |
if amount == 0: | |
pass | |
elif amount == 1: | |
# Note: Not the same as '\n', '\n' can cause the window content to | |
# scroll. | |
self.write_raw("\x1b[B") | |
else: | |
self.write_raw("\x1b[%iB" % amount) | |
def cursor_forward(self, amount: int) -> None: | |
if amount == 0: | |
pass | |
elif amount == 1: | |
self.write_raw("\x1b[C") | |
else: | |
self.write_raw("\x1b[%iC" % amount) | |
def cursor_backward(self, amount: int) -> None: | |
if amount == 0: | |
pass | |
elif amount == 1: | |
self.write_raw("\b") # '\x1b[D' | |
else: | |
self.write_raw("\x1b[%iD" % amount) | |
def hide_cursor(self) -> None: | |
self.write_raw("\x1b[?25l") | |
def show_cursor(self) -> None: | |
self.write_raw("\x1b[?12l\x1b[?25h") # Stop blinking cursor and show. | |
def set_cursor_shape(self, cursor_shape: CursorShape) -> None: | |
if cursor_shape == CursorShape._NEVER_CHANGE: | |
return | |
self._cursor_shape_changed = True | |
self.write_raw( | |
{ | |
CursorShape.BLOCK: "\x1b[2 q", | |
CursorShape.BEAM: "\x1b[6 q", | |
CursorShape.UNDERLINE: "\x1b[4 q", | |
CursorShape.BLINKING_BLOCK: "\x1b[1 q", | |
CursorShape.BLINKING_BEAM: "\x1b[5 q", | |
CursorShape.BLINKING_UNDERLINE: "\x1b[3 q", | |
}.get(cursor_shape, "") | |
) | |
def reset_cursor_shape(self) -> None: | |
"Reset cursor shape." | |
# (Only reset cursor shape, if we ever changed it.) | |
if self._cursor_shape_changed: | |
self._cursor_shape_changed = False | |
# Reset cursor shape. | |
self.write_raw("\x1b[0 q") | |
def flush(self) -> None: | |
""" | |
Write to output stream and flush. | |
""" | |
if not self._buffer: | |
return | |
data = "".join(self._buffer) | |
self._buffer = [] | |
flush_stdout(self.stdout, data) | |
def ask_for_cpr(self) -> None: | |
""" | |
Asks for a cursor position report (CPR). | |
""" | |
self.write_raw("\x1b[6n") | |
self.flush() | |
def responds_to_cpr(self) -> bool: | |
if not self.enable_cpr: | |
return False | |
# When the input is a tty, we assume that CPR is supported. | |
# It's not when the input is piped from Pexpect. | |
if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1": | |
return False | |
if is_dumb_terminal(self.term): | |
return False | |
try: | |
return self.stdout.isatty() | |
except ValueError: | |
return False # ValueError: I/O operation on closed file | |
def bell(self) -> None: | |
"Sound bell." | |
if self.enable_bell: | |
self.write_raw("\a") | |
self.flush() | |
def get_default_color_depth(self) -> ColorDepth: | |
""" | |
Return the default color depth for a vt100 terminal, according to the | |
our term value. | |
We prefer 256 colors almost always, because this is what most terminals | |
support these days, and is a good default. | |
""" | |
if self.default_color_depth is not None: | |
return self.default_color_depth | |
term = self.term | |
if term is None: | |
return ColorDepth.DEFAULT | |
if is_dumb_terminal(term): | |
return ColorDepth.DEPTH_1_BIT | |
if term in ("linux", "eterm-color"): | |
return ColorDepth.DEPTH_4_BIT | |
return ColorDepth.DEFAULT | |