""" 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/ """ import array import errno import sys from typing import ( IO, Callable, Dict, Hashable, Iterable, List, Optional, Sequence, Set, TextIO, Tuple, ) from prompt_toolkit.data_structures import Size from prompt_toolkit.output import Output from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs from .color_depth import ColorDepth __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, 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, 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 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]: # Thanks to fabric (fabfile.org), and # http://sqizit.bartletts.id.au/2011/02/14/pseudo-terminals-in-python/ """ Get the size of this pseudo terminal. :param fileno: stdout.fileno() :returns: A (rows, cols) tuple. """ # Inline imports, because these modules are not available on Windows. # (This file is used by ConEmuOutput, which is used on Windows.) import fcntl import termios # Buffer for the C call buf = array.array("h", [0, 0, 0, 0]) # Do TIOCGWINSZ (Get) # Note: We should not pass 'True' as a fourth parameter to 'ioctl'. (True # is the default.) This causes segmentation faults on some systems. # See: https://github.com/jonathanslenders/python-prompt-toolkit/pull/364 fcntl.ioctl(fileno, termios.TIOCGWINSZ, buf) # type: ignore # Return rows, cols return buf[0], buf[1] 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 write_binary: Encode the output before writing it. If `True` (the default), the `stdout` object is supposed to expose an `encoding` attribute. """ # 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: Optional[str] = None, write_binary: bool = True, ) -> None: assert all(hasattr(stdout, a) for a in ("write", "flush")) if write_binary: assert hasattr(stdout, "encoding") self._buffer: List[str] = [] self.stdout = stdout self.write_binary = write_binary self._get_size = get_size self.term = term or "xterm" # 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), } @classmethod def from_pty(cls, stdout: TextIO, term: Optional[str] = None) -> "Vt100_Output": """ Create an Output class from a pseudo terminal. (This will take the dimensions by reading the pseudo terminal attributes.) """ # 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. fd = stdout.fileno() if not stdout.isatty() and 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() 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) 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 colour 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 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") 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 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 flush(self) -> None: """ Write to output stream and flush. """ if not self._buffer: return data = "".join(self._buffer) try: # (We try to encode ourself, because that way we can replace # characters that don't exist in the character set, avoiding # UnicodeEncodeError crashes. E.g. u'\xb7' does not appear in 'ascii'.) # My Arch Linux installation of july 2015 reported 'ANSI_X3.4-1968' # for sys.stdout.encoding in xterm. out: IO if self.write_binary: if hasattr(self.stdout, "buffer"): out = self.stdout.buffer # Py3. else: out = self.stdout out.write(data.encode(self.stdout.encoding or "utf-8", "replace")) else: self.stdout.write(data) self.stdout.flush() except IOError as e: if e.args and e.args[0] == errno.EINTR: # Interrupted system call. Can happen in case of a window # resize signal. (Just ignore. The resize handler will render # again anyway.) pass elif e.args and e.args[0] == 0: # This can happen when there is a lot of output and the user # sends a KeyboardInterrupt by pressing Control-C. E.g. in # a Python REPL when we execute "while True: print('test')". # (The `ptpython` REPL uses this `Output` class instead of # `stdout` directly -- in order to be network transparent.) # So, just ignore. pass else: raise self._buffer = [] def ask_for_cpr(self) -> None: """ Asks for a cursor position report (CPR). """ self.write_raw("\x1b[6n") self.flush() def bell(self) -> None: " Sound bell. " self.write_raw("\a") self.flush()