Permalink
Browse files

Added 256-color support to Qt console escape sequence processing.

  • Loading branch information...
1 parent f531903 commit dd6217a544cb251fa9423605c000da5b233dedfc epatters committed Feb 27, 2011
@@ -26,30 +26,38 @@
# 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
#---------------------------------------------------------------------------
def __init__(self):
self.actions = []
+ self.color_map = self.default_color_map.copy()
self.reset_sgr()
def reset_sgr(self):
@@ -68,27 +76,32 @@ def split_string(self, string):
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 @@ def set_csi_code(self, command, params=[]):
"""
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 @@ def set_csi_code(self, command, params=[]):
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 @@ def set_sgr_code(self, code):
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,35 +243,66 @@ 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.
"""
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 @@ def set_background_color(self, color):
""" 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)
@@ -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 @@ def testClear(self):
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 @@ def testColors(self):
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 @@ def testScroll(self):
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)

0 comments on commit dd6217a

Please sign in to comment.