##// END OF EJS Templates
Added 256-color support to Qt console escape sequence processing.
epatters -
Show More
@@ -1,233 +1,334 b''
1 1 """ Utilities for processing ANSI escape codes and special ASCII characters.
2 2 """
3 3 #-----------------------------------------------------------------------------
4 4 # Imports
5 5 #-----------------------------------------------------------------------------
6 6
7 7 # Standard library imports
8 8 from collections import namedtuple
9 9 import re
10 10
11 11 # System library imports
12 12 from IPython.external.qt import QtCore, QtGui
13 13
14 14 #-----------------------------------------------------------------------------
15 15 # Constants and datatypes
16 16 #-----------------------------------------------------------------------------
17 17
18 18 # An action for erase requests (ED and EL commands).
19 19 EraseAction = namedtuple('EraseAction', ['action', 'area', 'erase_to'])
20 20
21 21 # An action for cursor move requests (CUU, CUD, CUF, CUB, CNL, CPL, CHA, CUP,
22 22 # and HVP commands).
23 23 # FIXME: Not implemented in AnsiCodeProcessor.
24 24 MoveAction = namedtuple('MoveAction', ['action', 'dir', 'unit', 'count'])
25 25
26 26 # An action for scroll requests (SU and ST) and form feeds.
27 27 ScrollAction = namedtuple('ScrollAction', ['action', 'dir', 'unit', 'count'])
28 28
29 # Regular expressions.
30 CSI_COMMANDS = 'ABCDEFGHJKSTfmnsu'
31 CSI_SUBPATTERN = '\[(.*?)([%s])' % CSI_COMMANDS
32 OSC_SUBPATTERN = '\](.*?)[\x07\x1b]'
33 ANSI_PATTERN = re.compile('\x01?\x1b(%s|%s)\x02?' % \
34 (CSI_SUBPATTERN, OSC_SUBPATTERN))
35 SPECIAL_PATTERN = re.compile('([\f])')
36
29 37 #-----------------------------------------------------------------------------
30 38 # Classes
31 39 #-----------------------------------------------------------------------------
32 40
33 41 class AnsiCodeProcessor(object):
34 42 """ Translates special ASCII characters and ANSI escape codes into readable
35 attributes.
43 attributes. It also supports a few non-standard, xterm-specific codes.
36 44 """
37 45
38 46 # Whether to increase intensity or set boldness for SGR code 1.
39 47 # (Different terminals handle this in different ways.)
40 48 bold_text_enabled = False
41 49
42 # Protected class variables.
43 _ansi_commands = 'ABCDEFGHJKSTfmnsu'
44 _ansi_pattern = re.compile('\x01?\x1b\[(.*?)([%s])\x02?' % _ansi_commands)
45 _special_pattern = re.compile('([\f])')
50 # We provide an empty default color map because subclasses will likely want
51 # to use a custom color format.
52 default_color_map = {}
46 53
47 54 #---------------------------------------------------------------------------
48 55 # AnsiCodeProcessor interface
49 56 #---------------------------------------------------------------------------
50 57
51 58 def __init__(self):
52 59 self.actions = []
60 self.color_map = self.default_color_map.copy()
53 61 self.reset_sgr()
54 62
55 63 def reset_sgr(self):
56 64 """ Reset graphics attributs to their default values.
57 65 """
58 66 self.intensity = 0
59 67 self.italic = False
60 68 self.bold = False
61 69 self.underline = False
62 70 self.foreground_color = None
63 71 self.background_color = None
64 72
65 73 def split_string(self, string):
66 74 """ Yields substrings for which the same escape code applies.
67 75 """
68 76 self.actions = []
69 77 start = 0
70 78
71 for match in self._ansi_pattern.finditer(string):
79 for match in ANSI_PATTERN.finditer(string):
72 80 raw = string[start:match.start()]
73 substring = self._special_pattern.sub(self._replace_special, raw)
81 substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
74 82 if substring or self.actions:
75 83 yield substring
76 84 start = match.end()
77 85
78 86 self.actions = []
87 groups = filter(lambda x: x is not None, match.groups())
88 params = [ param for param in groups[1].split(';') if param ]
89 if groups[0].startswith('['):
90 # Case 1: CSI code.
79 91 try:
80 params = []
81 for param in match.group(1).split(';'):
82 if param:
83 params.append(int(param))
92 params = map(int, params)
84 93 except ValueError:
85 # Silently discard badly formed escape codes.
94 # Silently discard badly formed codes.
86 95 pass
87 96 else:
88 self.set_csi_code(match.group(2), params)
97 self.set_csi_code(groups[2], params)
98
99 elif groups[0].startswith(']'):
100 # Case 2: OSC code.
101 self.set_osc_code(params)
89 102
90 103 raw = string[start:]
91 substring = self._special_pattern.sub(self._replace_special, raw)
104 substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
92 105 if substring or self.actions:
93 106 yield substring
94 107
95 108 def set_csi_code(self, command, params=[]):
96 109 """ Set attributes based on CSI (Control Sequence Introducer) code.
97 110
98 111 Parameters
99 112 ----------
100 113 command : str
101 114 The code identifier, i.e. the final character in the sequence.
102 115
103 116 params : sequence of integers, optional
104 117 The parameter codes for the command.
105 118 """
106 119 if command == 'm': # SGR - Select Graphic Rendition
107 120 if params:
108 for code in params:
109 self.set_sgr_code(code)
121 self.set_sgr_code(params)
110 122 else:
111 self.set_sgr_code(0)
123 self.set_sgr_code([0])
112 124
113 125 elif (command == 'J' or # ED - Erase Data
114 126 command == 'K'): # EL - Erase in Line
115 127 code = params[0] if params else 0
116 128 if 0 <= code <= 2:
117 129 area = 'screen' if command == 'J' else 'line'
118 130 if code == 0:
119 131 erase_to = 'end'
120 132 elif code == 1:
121 133 erase_to = 'start'
122 134 elif code == 2:
123 135 erase_to = 'all'
124 136 self.actions.append(EraseAction('erase', area, erase_to))
125 137
126 138 elif (command == 'S' or # SU - Scroll Up
127 139 command == 'T'): # SD - Scroll Down
128 140 dir = 'up' if command == 'S' else 'down'
129 141 count = params[0] if params else 1
130 142 self.actions.append(ScrollAction('scroll', dir, 'line', count))
131 143
132 def set_sgr_code(self, code):
133 """ Set attributes based on SGR (Select Graphic Rendition) code.
144 def set_osc_code(self, params):
145 """ Set attributes based on OSC (Operating System Command) parameters.
146
147 Parameters
148 ----------
149 params : sequence of str
150 The parameters for the command.
151 """
152 try:
153 command = int(params.pop(0))
154 except (IndexError, ValueError):
155 return
156
157 if command == 4:
158 # xterm-specific: set color number to color spec.
159 try:
160 color = int(params.pop(0))
161 spec = params.pop(0)
162 self.color_map[color] = self._parse_xterm_color_spec(spec)
163 except (IndexError, ValueError):
164 pass
165
166 def set_sgr_code(self, params):
167 """ Set attributes based on SGR (Select Graphic Rendition) codes.
168
169 Parameters
170 ----------
171 params : sequence of ints
172 A list of SGR codes for one or more SGR commands. Usually this
173 sequence will have one element per command, although certain
174 xterm-specific commands requires multiple elements.
134 175 """
176 # Always consume the first parameter.
177 if not params:
178 return
179 code = params.pop(0)
180
135 181 if code == 0:
136 182 self.reset_sgr()
137 183 elif code == 1:
138 184 if self.bold_text_enabled:
139 185 self.bold = True
140 186 else:
141 187 self.intensity = 1
142 188 elif code == 2:
143 189 self.intensity = 0
144 190 elif code == 3:
145 191 self.italic = True
146 192 elif code == 4:
147 193 self.underline = True
148 194 elif code == 22:
149 195 self.intensity = 0
150 196 self.bold = False
151 197 elif code == 23:
152 198 self.italic = False
153 199 elif code == 24:
154 200 self.underline = False
155 201 elif code >= 30 and code <= 37:
156 202 self.foreground_color = code - 30
203 elif code == 38 and params and params.pop(0) == 5:
204 # xterm-specific: 256 color support.
205 if params:
206 self.foreground_color = params.pop(0)
157 207 elif code == 39:
158 208 self.foreground_color = None
159 209 elif code >= 40 and code <= 47:
160 210 self.background_color = code - 40
211 elif code == 48 and params and params.pop(0) == 5:
212 # xterm-specific: 256 color support.
213 if params:
214 self.background_color = params.pop(0)
161 215 elif code == 49:
162 216 self.background_color = None
163 217
218 # Recurse with unconsumed parameters.
219 self.set_sgr_code(params)
220
164 221 #---------------------------------------------------------------------------
165 222 # Protected interface
166 223 #---------------------------------------------------------------------------
167 224
225 def _parse_xterm_color_spec(self, spec):
226 if spec.startswith('rgb:'):
227 return tuple(map(lambda x: int(x, 16), spec[4:].split('/')))
228 elif spec.startswith('rgbi:'):
229 return tuple(map(lambda x: int(float(x) * 255),
230 spec[5:].split('/')))
231 elif spec == '?':
232 raise ValueError('Unsupported xterm color spec')
233 return spec
234
168 235 def _replace_special(self, match):
169 236 special = match.group(1)
170 237 if special == '\f':
171 238 self.actions.append(ScrollAction('scroll', 'down', 'page', 1))
172 239 return ''
173 240
174 241
175 242 class QtAnsiCodeProcessor(AnsiCodeProcessor):
176 243 """ Translates ANSI escape codes into QTextCharFormats.
177 244 """
178 245
179 # A map from color codes to RGB colors.
180 default_map = (# Normal, Bright/Light ANSI color code
181 ('black', 'grey'), # 0: black
182 ('darkred', 'red'), # 1: red
183 ('darkgreen', 'lime'), # 2: green
184 ('brown', 'yellow'), # 3: yellow
185 ('darkblue', 'deepskyblue'), # 4: blue
186 ('darkviolet', 'magenta'), # 5: magenta
187 ('steelblue', 'cyan'), # 6: cyan
188 ('grey', 'white')) # 7: white
246 # A map from ANSI color codes to SVG color names or RGB(A) tuples.
247 darkbg_color_map = {
248 0 : 'black', # black
249 1 : 'darkred', # red
250 2 : 'darkgreen', # green
251 3 : 'brown', # yellow
252 4 : 'darkblue', # blue
253 5 : 'darkviolet', # magenta
254 6 : 'steelblue', # cyan
255 7 : 'grey', # white
256 8 : 'grey', # black (bright)
257 9 : 'red', # red (bright)
258 10 : 'lime', # green (bright)
259 11 : 'yellow', # yellow (bright)
260 12 : 'deepskyblue', # blue (bright)
261 13 : 'magenta', # magenta (bright)
262 14 : 'cyan', # cyan (bright)
263 15 : 'white' } # white (bright)
264
265 # Set the default color map for super class.
266 default_color_map = darkbg_color_map.copy()
267
268 def get_color(self, color, intensity=0):
269 """ Returns a QColor for a given color code, or None if one cannot be
270 constructed.
271 """
272 if color is None:
273 return None
189 274
190 def __init__(self):
191 super(QtAnsiCodeProcessor, self).__init__()
192 self.color_map = self.default_map
275 # Adjust for intensity, if possible.
276 if color < 8 and intensity > 0:
277 color += 8
278
279 constructor = self.color_map.get(color, None)
280 if isinstance(constructor, basestring):
281 # If this is an X11 color name, we just hope there is a close SVG
282 # color name. We could use QColor's static method
283 # 'setAllowX11ColorNames()', but this is global and only available
284 # on X11. It seems cleaner to aim for uniformity of behavior.
285 return QtGui.QColor(constructor)
286
287 elif isinstance(constructor, (tuple, list)):
288 return QtGui.QColor(*constructor)
289
290 return None
193 291
194 292 def get_format(self):
195 293 """ Returns a QTextCharFormat that encodes the current style attributes.
196 294 """
197 295 format = QtGui.QTextCharFormat()
198 296
199 297 # Set foreground color
200 if self.foreground_color is not None:
201 color = self.color_map[self.foreground_color][self.intensity]
202 format.setForeground(QtGui.QColor(color))
298 qcolor = self.get_color(self.foreground_color, self.intensity)
299 if qcolor is not None:
300 format.setForeground(qcolor)
203 301
204 302 # Set background color
205 if self.background_color is not None:
206 color = self.color_map[self.background_color][self.intensity]
207 format.setBackground(QtGui.QColor(color))
303 qcolor = self.get_color(self.background_color, self.intensity)
304 if qcolor is not None:
305 format.setBackground(qcolor)
208 306
209 307 # Set font weight/style options
210 308 if self.bold:
211 309 format.setFontWeight(QtGui.QFont.Bold)
212 310 else:
213 311 format.setFontWeight(QtGui.QFont.Normal)
214 312 format.setFontItalic(self.italic)
215 313 format.setFontUnderline(self.underline)
216 314
217 315 return format
218 316
219 317 def set_background_color(self, color):
220 318 """ Given a background color (a QColor), attempt to set a color map
221 319 that will be aesthetically pleasing.
222 320 """
223 if color.value() < 127:
224 # Colors appropriate for a terminal with a dark background.
225 self.color_map = self.default_map
321 # Set a new default color map.
322 self.default_color_map = self.darkbg_color_map.copy()
226 323
227 else:
324 if color.value() >= 127:
228 325 # Colors appropriate for a terminal with a light background. For
229 326 # now, only use non-bright colors...
230 self.color_map = [ (pair[0], pair[0]) for pair in self.default_map ]
327 for i in xrange(8):
328 self.default_color_map[i + 8] = self.default_color_map[i]
231 329
232 330 # ...and replace white with black.
233 self.color_map[7] = ('black', 'black')
331 self.default_color_map[7] = self.default_color_map[15] = 'black'
332
333 # Update the current color map with the new defaults.
334 self.color_map.update(self.default_color_map)
@@ -1,84 +1,107 b''
1 1 # Standard library imports
2 2 import unittest
3 3
4 4 # Local imports
5 5 from IPython.frontend.qt.console.ansi_code_processor import AnsiCodeProcessor
6 6
7 7
8 8 class TestAnsiCodeProcessor(unittest.TestCase):
9 9
10 10 def setUp(self):
11 11 self.processor = AnsiCodeProcessor()
12 12
13 def testClear(self):
13 def test_clear(self):
14 """ Do control sequences for clearing the console work?
15 """
14 16 string = '\x1b[2J\x1b[K'
15 17 i = -1
16 18 for i, substring in enumerate(self.processor.split_string(string)):
17 19 if i == 0:
18 20 self.assertEquals(len(self.processor.actions), 1)
19 21 action = self.processor.actions[0]
20 22 self.assertEquals(action.action, 'erase')
21 23 self.assertEquals(action.area, 'screen')
22 24 self.assertEquals(action.erase_to, 'all')
23 25 elif i == 1:
24 26 self.assertEquals(len(self.processor.actions), 1)
25 27 action = self.processor.actions[0]
26 28 self.assertEquals(action.action, 'erase')
27 29 self.assertEquals(action.area, 'line')
28 30 self.assertEquals(action.erase_to, 'end')
29 31 else:
30 32 self.fail('Too many substrings.')
31 33 self.assertEquals(i, 1, 'Too few substrings.')
32 34
33 def testColors(self):
34 string = "first\x1b[34mblue\x1b[0mlast"
35 def test_colors(self):
36 """ Do basic controls sequences for colors work?
37 """
38 string = 'first\x1b[34mblue\x1b[0mlast'
35 39 i = -1
36 40 for i, substring in enumerate(self.processor.split_string(string)):
37 41 if i == 0:
38 42 self.assertEquals(substring, 'first')
39 43 self.assertEquals(self.processor.foreground_color, None)
40 44 elif i == 1:
41 45 self.assertEquals(substring, 'blue')
42 46 self.assertEquals(self.processor.foreground_color, 4)
43 47 elif i == 2:
44 48 self.assertEquals(substring, 'last')
45 49 self.assertEquals(self.processor.foreground_color, None)
46 50 else:
47 51 self.fail('Too many substrings.')
48 52 self.assertEquals(i, 2, 'Too few substrings.')
49 53
50 def testScroll(self):
54 def test_colors_xterm(self):
55 """ Do xterm-specific control sequences for colors work?
56 """
57 string = '\x1b]4;20;rgb:ff/ff/ff\x1b' \
58 '\x1b]4;25;rgbi:1.0/1.0/1.0\x1b'
59 substrings = list(self.processor.split_string(string))
60 desired = { 20 : (255, 255, 255),
61 25 : (255, 255, 255) }
62 self.assertEquals(self.processor.color_map, desired)
63
64 string = '\x1b[38;5;20m\x1b[48;5;25m'
65 substrings = list(self.processor.split_string(string))
66 self.assertEquals(self.processor.foreground_color, 20)
67 self.assertEquals(self.processor.background_color, 25)
68
69 def test_scroll(self):
70 """ Do control sequences for scrolling the buffer work?
71 """
51 72 string = '\x1b[5S\x1b[T'
52 73 i = -1
53 74 for i, substring in enumerate(self.processor.split_string(string)):
54 75 if i == 0:
55 76 self.assertEquals(len(self.processor.actions), 1)
56 77 action = self.processor.actions[0]
57 78 self.assertEquals(action.action, 'scroll')
58 79 self.assertEquals(action.dir, 'up')
59 80 self.assertEquals(action.unit, 'line')
60 81 self.assertEquals(action.count, 5)
61 82 elif i == 1:
62 83 self.assertEquals(len(self.processor.actions), 1)
63 84 action = self.processor.actions[0]
64 85 self.assertEquals(action.action, 'scroll')
65 86 self.assertEquals(action.dir, 'down')
66 87 self.assertEquals(action.unit, 'line')
67 88 self.assertEquals(action.count, 1)
68 89 else:
69 90 self.fail('Too many substrings.')
70 91 self.assertEquals(i, 1, 'Too few substrings.')
71 92
72 def testSpecials(self):
93 def test_specials(self):
94 """ Are special characters processed correctly?
95 """
73 96 string = '\f' # form feed
74 97 self.assertEquals(list(self.processor.split_string(string)), [''])
75 98 self.assertEquals(len(self.processor.actions), 1)
76 99 action = self.processor.actions[0]
77 100 self.assertEquals(action.action, 'scroll')
78 101 self.assertEquals(action.dir, 'down')
79 102 self.assertEquals(action.unit, 'page')
80 103 self.assertEquals(action.count, 1)
81 104
82 105
83 106 if __name__ == '__main__':
84 107 unittest.main()
General Comments 0
You need to be logged in to leave comments. Login now