123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747 |
- """
- 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
- @classmethod
- 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;%s\x07" % 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()
- @property
- 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
|