diff --git a/IPython/frontend/qt/console/ansi_code_processor.py b/IPython/frontend/qt/console/ansi_code_processor.py index f8da145..cb53013 100644 --- a/IPython/frontend/qt/console/ansi_code_processor.py +++ b/IPython/frontend/qt/console/ansi_code_processor.py @@ -26,23 +26,30 @@ MoveAction = namedtuple('MoveAction', ['action', 'dir', 'unit', 'count']) # An action for scroll requests (SU and ST) and form feeds. ScrollAction = namedtuple('ScrollAction', ['action', 'dir', 'unit', 'count']) +# Regular expressions. +CSI_COMMANDS = 'ABCDEFGHJKSTfmnsu' +CSI_SUBPATTERN = '\[(.*?)([%s])' % CSI_COMMANDS +OSC_SUBPATTERN = '\](.*?)[\x07\x1b]' +ANSI_PATTERN = re.compile('\x01?\x1b(%s|%s)\x02?' % \ + (CSI_SUBPATTERN, OSC_SUBPATTERN)) +SPECIAL_PATTERN = re.compile('([\f])') + #----------------------------------------------------------------------------- # Classes #----------------------------------------------------------------------------- class AnsiCodeProcessor(object): """ Translates special ASCII characters and ANSI escape codes into readable - attributes. + attributes. It also supports a few non-standard, xterm-specific codes. """ # Whether to increase intensity or set boldness for SGR code 1. # (Different terminals handle this in different ways.) - bold_text_enabled = False + bold_text_enabled = False - # Protected class variables. - _ansi_commands = 'ABCDEFGHJKSTfmnsu' - _ansi_pattern = re.compile('\x01?\x1b\[(.*?)([%s])\x02?' % _ansi_commands) - _special_pattern = re.compile('([\f])') + # We provide an empty default color map because subclasses will likely want + # to use a custom color format. + default_color_map = {} #--------------------------------------------------------------------------- # AnsiCodeProcessor interface @@ -50,6 +57,7 @@ class AnsiCodeProcessor(object): def __init__(self): self.actions = [] + self.color_map = self.default_color_map.copy() self.reset_sgr() def reset_sgr(self): @@ -68,27 +76,32 @@ class AnsiCodeProcessor(object): self.actions = [] start = 0 - for match in self._ansi_pattern.finditer(string): + for match in ANSI_PATTERN.finditer(string): raw = string[start:match.start()] - substring = self._special_pattern.sub(self._replace_special, raw) + substring = SPECIAL_PATTERN.sub(self._replace_special, raw) if substring or self.actions: yield substring start = match.end() self.actions = [] - try: - params = [] - for param in match.group(1).split(';'): - if param: - params.append(int(param)) - except ValueError: - # Silently discard badly formed escape codes. - pass - else: - self.set_csi_code(match.group(2), params) + groups = filter(lambda x: x is not None, match.groups()) + params = [ param for param in groups[1].split(';') if param ] + if groups[0].startswith('['): + # Case 1: CSI code. + try: + params = map(int, params) + except ValueError: + # Silently discard badly formed codes. + pass + else: + self.set_csi_code(groups[2], params) + + elif groups[0].startswith(']'): + # Case 2: OSC code. + self.set_osc_code(params) raw = string[start:] - substring = self._special_pattern.sub(self._replace_special, raw) + substring = SPECIAL_PATTERN.sub(self._replace_special, raw) if substring or self.actions: yield substring @@ -105,10 +118,9 @@ class AnsiCodeProcessor(object): """ if command == 'm': # SGR - Select Graphic Rendition if params: - for code in params: - self.set_sgr_code(code) + self.set_sgr_code(params) else: - self.set_sgr_code(0) + self.set_sgr_code([0]) elif (command == 'J' or # ED - Erase Data command == 'K'): # EL - Erase in Line @@ -128,10 +140,44 @@ class AnsiCodeProcessor(object): dir = 'up' if command == 'S' else 'down' count = params[0] if params else 1 self.actions.append(ScrollAction('scroll', dir, 'line', count)) + + def set_osc_code(self, params): + """ Set attributes based on OSC (Operating System Command) parameters. - def set_sgr_code(self, code): - """ Set attributes based on SGR (Select Graphic Rendition) code. + Parameters + ---------- + params : sequence of str + The parameters for the command. """ + try: + command = int(params.pop(0)) + except (IndexError, ValueError): + return + + if command == 4: + # xterm-specific: set color number to color spec. + try: + color = int(params.pop(0)) + spec = params.pop(0) + self.color_map[color] = self._parse_xterm_color_spec(spec) + except (IndexError, ValueError): + pass + + def set_sgr_code(self, params): + """ Set attributes based on SGR (Select Graphic Rendition) codes. + + Parameters + ---------- + params : sequence of ints + A list of SGR codes for one or more SGR commands. Usually this + sequence will have one element per command, although certain + xterm-specific commands requires multiple elements. + """ + # Always consume the first parameter. + if not params: + return + code = params.pop(0) + if code == 0: self.reset_sgr() elif code == 1: @@ -154,17 +200,38 @@ class AnsiCodeProcessor(object): self.underline = False elif code >= 30 and code <= 37: self.foreground_color = code - 30 + elif code == 38 and params and params.pop(0) == 5: + # xterm-specific: 256 color support. + if params: + self.foreground_color = params.pop(0) elif code == 39: self.foreground_color = None elif code >= 40 and code <= 47: self.background_color = code - 40 + elif code == 48 and params and params.pop(0) == 5: + # xterm-specific: 256 color support. + if params: + self.background_color = params.pop(0) elif code == 49: self.background_color = None + # Recurse with unconsumed parameters. + self.set_sgr_code(params) + #--------------------------------------------------------------------------- # Protected interface #--------------------------------------------------------------------------- + def _parse_xterm_color_spec(self, spec): + if spec.startswith('rgb:'): + return tuple(map(lambda x: int(x, 16), spec[4:].split('/'))) + elif spec.startswith('rgbi:'): + return tuple(map(lambda x: int(float(x) * 255), + spec[5:].split('/'))) + elif spec == '?': + raise ValueError('Unsupported xterm color spec') + return spec + def _replace_special(self, match): special = match.group(1) if special == '\f': @@ -176,20 +243,51 @@ class QtAnsiCodeProcessor(AnsiCodeProcessor): """ Translates ANSI escape codes into QTextCharFormats. """ - # A map from color codes to RGB colors. - default_map = (# Normal, Bright/Light ANSI color code - ('black', 'grey'), # 0: black - ('darkred', 'red'), # 1: red - ('darkgreen', 'lime'), # 2: green - ('brown', 'yellow'), # 3: yellow - ('darkblue', 'deepskyblue'), # 4: blue - ('darkviolet', 'magenta'), # 5: magenta - ('steelblue', 'cyan'), # 6: cyan - ('grey', 'white')) # 7: white + # A map from ANSI color codes to SVG color names or RGB(A) tuples. + darkbg_color_map = { + 0 : 'black', # black + 1 : 'darkred', # red + 2 : 'darkgreen', # green + 3 : 'brown', # yellow + 4 : 'darkblue', # blue + 5 : 'darkviolet', # magenta + 6 : 'steelblue', # cyan + 7 : 'grey', # white + 8 : 'grey', # black (bright) + 9 : 'red', # red (bright) + 10 : 'lime', # green (bright) + 11 : 'yellow', # yellow (bright) + 12 : 'deepskyblue', # blue (bright) + 13 : 'magenta', # magenta (bright) + 14 : 'cyan', # cyan (bright) + 15 : 'white' } # white (bright) + + # Set the default color map for super class. + default_color_map = darkbg_color_map.copy() + + def get_color(self, color, intensity=0): + """ Returns a QColor for a given color code, or None if one cannot be + constructed. + """ + if color is None: + return None - def __init__(self): - super(QtAnsiCodeProcessor, self).__init__() - self.color_map = self.default_map + # Adjust for intensity, if possible. + if color < 8 and intensity > 0: + color += 8 + + constructor = self.color_map.get(color, None) + if isinstance(constructor, basestring): + # If this is an X11 color name, we just hope there is a close SVG + # color name. We could use QColor's static method + # 'setAllowX11ColorNames()', but this is global and only available + # on X11. It seems cleaner to aim for uniformity of behavior. + return QtGui.QColor(constructor) + + elif isinstance(constructor, (tuple, list)): + return QtGui.QColor(*constructor) + + return None def get_format(self): """ Returns a QTextCharFormat that encodes the current style attributes. @@ -197,14 +295,14 @@ class QtAnsiCodeProcessor(AnsiCodeProcessor): format = QtGui.QTextCharFormat() # Set foreground color - if self.foreground_color is not None: - color = self.color_map[self.foreground_color][self.intensity] - format.setForeground(QtGui.QColor(color)) + qcolor = self.get_color(self.foreground_color, self.intensity) + if qcolor is not None: + format.setForeground(qcolor) # Set background color - if self.background_color is not None: - color = self.color_map[self.background_color][self.intensity] - format.setBackground(QtGui.QColor(color)) + qcolor = self.get_color(self.background_color, self.intensity) + if qcolor is not None: + format.setBackground(qcolor) # Set font weight/style options if self.bold: @@ -220,14 +318,17 @@ class QtAnsiCodeProcessor(AnsiCodeProcessor): """ Given a background color (a QColor), attempt to set a color map that will be aesthetically pleasing. """ - if color.value() < 127: - # Colors appropriate for a terminal with a dark background. - self.color_map = self.default_map + # Set a new default color map. + self.default_color_map = self.darkbg_color_map.copy() - else: + if color.value() >= 127: # Colors appropriate for a terminal with a light background. For # now, only use non-bright colors... - self.color_map = [ (pair[0], pair[0]) for pair in self.default_map ] + for i in xrange(8): + self.default_color_map[i + 8] = self.default_color_map[i] # ...and replace white with black. - self.color_map[7] = ('black', 'black') + self.default_color_map[7] = self.default_color_map[15] = 'black' + + # Update the current color map with the new defaults. + self.color_map.update(self.default_color_map) diff --git a/IPython/frontend/qt/console/tests/test_ansi_code_processor.py b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py index e6acb43..f71334a 100644 --- a/IPython/frontend/qt/console/tests/test_ansi_code_processor.py +++ b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py @@ -10,7 +10,9 @@ class TestAnsiCodeProcessor(unittest.TestCase): def setUp(self): self.processor = AnsiCodeProcessor() - def testClear(self): + def test_clear(self): + """ Do control sequences for clearing the console work? + """ string = '\x1b[2J\x1b[K' i = -1 for i, substring in enumerate(self.processor.split_string(string)): @@ -30,8 +32,10 @@ class TestAnsiCodeProcessor(unittest.TestCase): self.fail('Too many substrings.') self.assertEquals(i, 1, 'Too few substrings.') - def testColors(self): - string = "first\x1b[34mblue\x1b[0mlast" + def test_colors(self): + """ Do basic controls sequences for colors work? + """ + string = 'first\x1b[34mblue\x1b[0mlast' i = -1 for i, substring in enumerate(self.processor.split_string(string)): if i == 0: @@ -47,7 +51,24 @@ class TestAnsiCodeProcessor(unittest.TestCase): self.fail('Too many substrings.') self.assertEquals(i, 2, 'Too few substrings.') - def testScroll(self): + def test_colors_xterm(self): + """ Do xterm-specific control sequences for colors work? + """ + string = '\x1b]4;20;rgb:ff/ff/ff\x1b' \ + '\x1b]4;25;rgbi:1.0/1.0/1.0\x1b' + substrings = list(self.processor.split_string(string)) + desired = { 20 : (255, 255, 255), + 25 : (255, 255, 255) } + self.assertEquals(self.processor.color_map, desired) + + string = '\x1b[38;5;20m\x1b[48;5;25m' + substrings = list(self.processor.split_string(string)) + self.assertEquals(self.processor.foreground_color, 20) + self.assertEquals(self.processor.background_color, 25) + + def test_scroll(self): + """ Do control sequences for scrolling the buffer work? + """ string = '\x1b[5S\x1b[T' i = -1 for i, substring in enumerate(self.processor.split_string(string)): @@ -69,7 +90,9 @@ class TestAnsiCodeProcessor(unittest.TestCase): self.fail('Too many substrings.') self.assertEquals(i, 1, 'Too few substrings.') - def testSpecials(self): + def test_specials(self): + """ Are special characters processed correctly? + """ string = '\f' # form feed self.assertEquals(list(self.processor.split_string(string)), ['']) self.assertEquals(len(self.processor.actions), 1)