""" This module provides parsers to create ColouredText objects from embedded control strings. """ from __future__ import division from __future__ import absolute_import from __future__ import print_function from __future__ import unicode_literals import re from builtins import str from future.utils import with_metaclass from abc import ABCMeta, abstractmethod from logging import getLogger import asciimatics.constants as constants from asciimatics.utilities import _DotDict # Diagnostic logging logger = getLogger(__name__) class Parser(with_metaclass(ABCMeta, object)): """ Abstract class to represent text parsers that extract colour control codes from raw text and convert them to displayable text and associated colour maps. """ #: Command to display some text. Parameter is the text to display DISPLAY_TEXT = 0 #: Command to change active colour tuple. Parameters are the 3-tuple of (fg, attr, bg) CHANGE_COLOURS = 1 #: Command to move cursor to abs position. Parameters are (x, y) where each are absolute positions. MOVE_ABSOLUTE = 2 #: Command to move cursor to relative position. Parameters are (x, y) where each are relative positions. MOVE_RELATIVE = 3 #: Command to delete part of the current line. Params are 0, 1 and 2 for end, start, all. DELETE_LINE = 4 #: Command to delete next N characters from this line. DELETE_CHARS = 5 #: Next tab stop NEXT_TAB = 6 #: Set cursor visibility. Param is boolean setting True=visible SHOW_CURSOR = 7 #: Clear the screen. No parameters. CLEAR_SCREEN = 8 #: Save the cursor position. No parameters. SAVE_CURSOR = 9 #: Restore the cursor position. No parameters. RESTORE_CURSOR = 10 def __init__(self): """ Initialize the parser. """ self._state = None def reset(self, text, colours): """ Reset the parser to analyze the supplied raw text. :param text: raw text to process. :param colours: colour tuple to initialise the colour map. """ self._state = _DotDict() self._state.text = text # Force colours to be mutable (in case a tuple was passed in). self._state.attributes = [x for x in colours] if colours else None @abstractmethod def parse(self): """ Generator to return coloured text from raw text. Generally returns a stream of text/color tuple/offset tuples. If there is a colour update with no visible text, the first element of the tuple may be None. :returns: a 3-tuple of (start offset in raw text, command to execute, parameters) """ def append(self, text): """ Append more text to the current text being processed. :param text: raw text to process. """ self._state.text += text class ControlCodeParser(Parser): """ Parser to replace all control codes with a readable version - e.g. "^M" for carriage return. """ def parse(self): """ Generator to return coloured text from raw text. :returns: a 3-tuple of (start offset in raw text, command to execute, parameters) """ if self._state.attributes: yield (0, Parser.CHANGE_COLOURS, tuple(self._attributes)) offset = 0 while len(self._state.text) > 0: letter = self._state.text[0] if ord(letter) < 32: yield (offset, Parser.DISPLAY_TEXT, "^" + chr(ord("@") + ord(letter))) else: yield (offset, Parser.DISPLAY_TEXT, letter) offset += 1 self._state.text = self._state.text[1:] class AsciimaticsParser(Parser): """ Parser to handle Asciimatics rendering escape strings. """ # Regular expression for use to find colour sequences in multi-colour text. # It should match ${n}, ${m,n} or ${m,n,o} _colour_sequence = re.compile(constants.COLOUR_REGEX) def parse(self): """ Generator to return coloured text from raw text. :returns: a 3-tuple of (start offset in raw text, command to execute, parameters) """ if self._state.attributes: yield (0, Parser.CHANGE_COLOURS, tuple(self._state.attributes)) offset = last_offset = 0 while len(self._state.text) > 0: match = self._colour_sequence.match(str(self._state.text)) if match is None: yield (last_offset, Parser.DISPLAY_TEXT, self._state.text[0]) self._state.text = self._state.text[1:] offset += 1 last_offset = offset else: # The regexp either matches: # - 2,3,4 for ${c,a,b} # - 5,6 for ${c,a} # - 7 for ${c}. if match.group(2) is not None: attributes = (int(match.group(2)), constants.MAPPING_ATTRIBUTES[match.group(3)], int(match.group(4))) elif match.group(5) is not None: attributes = (int(match.group(5)), constants.MAPPING_ATTRIBUTES[match.group(6)], None) else: attributes = (int(match.group(7)), 0, None) yield (last_offset, Parser.CHANGE_COLOURS, attributes) offset += 3 + len(match.group(1)) self._state.text = match.group(8) class AnsiTerminalParser(Parser): """ Parser to handle ANSI terminal escape codes. """ # Regular expression for use to find colour sequences in multi-colour text. _colour_sequence = re.compile(r"^(\x1B\[([^@-~]*)([@-~]))(.*)") _os_cmd = re.compile(r"^(\x1B].*\x07)(.*)") def reset(self, text, colours): """ Reset the parser to analyze the supplied raw text. :param text: raw text to process. :param colours: colour tuple to initialise the colour map. """ super(AnsiTerminalParser, self).reset(text, colours) if self._state.attributes is None: self._state.init_colours = False self._state.attributes = [None, None, None] else: self._state.init_colours = True self._state.offset = 0 self._state.last_offset = 0 self._state.cursor = 0 def parse(self): def _handle_escape(st): match = self._colour_sequence.match(str(st.text)) if match is None: # Not a CSI sequence... Check for some other options. match = self._os_cmd.match(str(st.text)) if match: # OS command - just swallow it. return len(match.group(1)), None elif len(st.text) > 1 and st.text[1] == "M": # Reverse Index - i.e. move up/scroll return 2, [(st.last_offset, Parser.MOVE_RELATIVE, (0, -1))] # Unknown escape - guess how many characters to ignore - most likely just the next char # unless we can see the start of a new sequence. logger.debug("Ignoring: %s", st.text[0:2]) if len(st.text) < 2: return -1, None if st.text[1] in ("[", "]"): return -1, None return (2, None) if st.text[1] != "(" else (3, None) else: # CSI sequence - look for the various options... results = [] if match.group(3) == "m": # We have found a SGR escape sequence ( CSI ... m ). These have zero or more # embedded arguments, so create a simple FSM to process the parameter stream. in_set_mode = False in_index_mode = False in_rgb_mode = False skip_size = 0 attribute_index = 0 last_attributes = tuple(st.attributes) for parameter in match.group(2).split(";"): try: parameter = int(parameter) except ValueError: parameter = 0 if in_set_mode: # We are processing a set fore/background colour code if parameter == 5: in_index_mode = True elif parameter == 2: in_rgb_mode = True skip_size = 3 else: logger.info(("Unexpected colour setting", parameter)) break in_set_mode = False elif in_index_mode: # We are processing a 5;n sequence for colour indeces st.attributes[attribute_index] = parameter in_index_mode = False elif in_rgb_mode: # We are processing a 2;r;g;b sequence for RGB colours - just ignore. skip_size -= 1 if skip_size <= 0: in_rgb_mode = False else: # top-level stream processing if parameter == 0: # Reset st.attributes = [constants.COLOUR_WHITE, constants.A_NORMAL, constants.COLOUR_BLACK] elif parameter == 1: # Bold st.attributes[1] = constants.A_BOLD elif parameter in (2, 22): # Faint/normal - faint not supported so treat as normal st.attributes[1] = constants.A_NORMAL elif parameter == 7: # Inverse st.attributes[1] = constants.A_REVERSE elif parameter == 27: # Inverse off - assume that means normal st.attributes[1] = constants.A_NORMAL elif parameter in range(30, 38): # Standard foreground colours st.attributes[0] = parameter - 30 elif parameter in range(40, 48): # Standard background colours st.attributes[2] = parameter - 40 elif parameter == 38: # Set foreground colour - next parameter is either 5 (index) or 2 (RGB color) in_set_mode = True attribute_index = 0 elif parameter == 48: # Set background colour - next parameter is either 5 (index) or 2 (RGB color) in_set_mode = True attribute_index = 2 elif parameter == 39: # Default foreground colour st.attributes[0] = -1 elif parameter == 49: # Default background colour st.attributes[2] = -1 elif parameter in range(90, 98): # Bright foreground colours st.attributes[0] = parameter - 82 elif parameter in range(100, 108): # Bright background colours st.attributes[2] = parameter - 92 else: logger.debug("Ignoring parameter: %s", parameter) new_attributes = tuple(st.attributes) if last_attributes != new_attributes: results.append((st.last_offset, Parser.CHANGE_COLOURS, new_attributes)) elif match.group(3) == "K": # This is a line delete sequence. Parameter defines which parts to delete. param = match.group(2) if param in ("", "0"): # Delete to end of line results.append((self._state.last_offset, Parser.DELETE_LINE, 0)) elif param == "1": # Delete from start of line results.append((self._state.last_offset, Parser.DELETE_LINE, 1)) elif param == "2": # Delete whole line results.append((self._state.last_offset, Parser.DELETE_LINE, 2)) elif match.group(3) == "P": # This is a character delete sequence. Parameter defines how many to delete. param = 1 if match.group(2) == "" else int(match.group(2)) results.append((self._state.last_offset, Parser.DELETE_CHARS, param)) elif match.group(3) == "A": # Move cursor up. Parameter defines how far to move.. param = 1 if match.group(2) == "" else int(match.group(2)) results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (0, -param))) elif match.group(3) == "B": # Move cursor down. Parameter defines how far to move.. param = 1 if match.group(2) == "" else int(match.group(2)) results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (0, param))) elif match.group(3) == "C": # Move cursor forwards. Parameter defines how far to move.. param = 1 if match.group(2) == "" else int(match.group(2)) results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (param, 0))) elif match.group(3) == "D": # Move cursor backwards. Parameter defines how far to move.. param = 1 if match.group(2) == "" else int(match.group(2)) results.append((self._state.last_offset, Parser.MOVE_RELATIVE, (-param, 0))) elif match.group(3) == "H": # Move cursor to specified position. x, y = 0, 0 params = match.group(2).split(";") y = int(params[0]) - 1 if params[0] != "" else 0 if len(params) > 1: x = int(params[1]) - 1 if params[1] != "" else 0 results.append((self._state.last_offset, Parser.MOVE_ABSOLUTE, (x, y))) elif match.group(3) == "h" and match.group(2) == "?25": # Various DEC private mode commands - look for cursor visibility, ignore others. results.append((self._state.last_offset, Parser.SHOW_CURSOR, True)) elif match.group(3) == "l" and match.group(2) == "?25": # Various DEC private mode commands - look for cursor visibility, ignore others. results.append((self._state.last_offset, Parser.SHOW_CURSOR, False)) elif match.group(3) == "h" and match.group(2) == "?1049": # This should really create an alternate screen, but clearing is a close # approximation results.append((self._state.last_offset, Parser.CLEAR_SCREEN, None)) elif match.group(3) == "l" and match.group(2) == "?1049": # This should really return to the normal screen, but clearing is a close # approximation results.append((self._state.last_offset, Parser.CLEAR_SCREEN, None)) elif match.group(3) == "J" and match.group(2) == "2": # Clear the screen. results.append((self._state.last_offset, Parser.CLEAR_SCREEN, None)) elif match.group(3) == "s": # Save cursor pos results.append((self._state.last_offset, Parser.SAVE_CURSOR, None)) elif match.group(3) == "u": # Restore cursor pos results.append((self._state.last_offset, Parser.RESTORE_CURSOR, None)) else: logger.debug("Ignoring control: %s", match.group(1)) return len(match.group(1)), results if self._state.init_colours: self._state.init_colours = False yield (0, Parser.CHANGE_COLOURS, self._state.attributes) while len(self._state.text) > 0: char = ord(self._state.text[0]) new_offset = 1 if char > 31: yield (self._state.last_offset, Parser.DISPLAY_TEXT, self._state.text[0]) self._state.last_offset = self._state.offset + 1 elif char == 7: # Bell - ignore pass elif char == 8: # Back space yield (self._state.last_offset, Parser.MOVE_RELATIVE, (-1, 0)) elif char == 9: # Tab yield (self._state.last_offset, Parser.NEXT_TAB, None) elif char == 13: # Carriage return yield (self._state.last_offset, Parser.MOVE_ABSOLUTE, (0, None)) elif char == 27: new_offset, results = _handle_escape(self._state) if new_offset == -1: break if results is not None: for result in results: yield result else: logger.debug("Ignoring character: %d", char) yield (self._state.last_offset, Parser.DISPLAY_TEXT, " ") self._state.last_offset = self._state.offset + 1 self._state.offset += new_offset self._state.text = self._state.text[new_offset:]