From 57bf73a1cc41c9a2f29cb6c86e341e269c4d0ee2 2010-08-06 21:18:25
From: epatters <epatters@enthought.com>
Date: 2010-08-06 21:18:25
Subject: [PATCH] * Moved AnsiCodeProcessor to separate file, refactored its API, and added unit tests.
* Improved traceback display.
* Modified FrontendWidget to use tabs instead of spaces for convenient deletion.

---

diff --git a/IPython/frontend/qt/console/ansi_code_processor.py b/IPython/frontend/qt/console/ansi_code_processor.py
new file mode 100644
index 0000000..6365d1c
--- /dev/null
+++ b/IPython/frontend/qt/console/ansi_code_processor.py
@@ -0,0 +1,131 @@
+# Standard library imports
+import re
+
+# System library imports
+from PyQt4 import QtCore, QtGui
+
+
+class AnsiCodeProcessor(object):
+    """ Translates ANSI escape codes into readable attributes.
+    """
+
+    # Protected class variables.
+    _ansi_commands = 'ABCDEFGHJKSTfmnsu'
+    _ansi_pattern = re.compile('\x01?\x1b\[(.*?)([%s])\x02?' % _ansi_commands)
+
+    def __init__(self):
+        self.reset()
+
+    def reset(self):
+        """ Reset attributs to their default values.
+        """
+        self.intensity = 0
+        self.italic = False
+        self.bold = False
+        self.underline = False
+        self.foreground_color = None
+        self.background_color = None
+
+    def split_string(self, string):
+        """ Yields substrings for which the same escape code applies.
+        """
+        start = 0
+
+        for match in self._ansi_pattern.finditer(string):
+            substring = string[start:match.start()]
+            if substring:
+                yield substring
+            start = match.end()
+
+            params = map(int, match.group(1).split(';'))
+            self.set_csi_code(match.group(2), params)
+
+        substring = string[start:]
+        if substring:
+            yield substring
+
+    def set_csi_code(self, command, params=[]):
+        """ Set attributes based on CSI (Control Sequence Introducer) code.
+
+        Parameters
+        ----------
+        command : str
+            The code identifier, i.e. the final character in the sequence.
+        
+        params : sequence of integers, optional
+            The parameter codes for the command.
+        """
+        if command == 'm': # SGR - Select Graphic Rendition
+            for code in params:
+                self.set_sgr_code(code)
+        
+    def set_sgr_code(self, code):
+        """ Set attributes based on SGR (Select Graphic Rendition) code.
+        """
+        if code == 0:
+            self.reset()
+        elif code == 1:
+            self.intensity = 1
+            self.bold = True
+        elif code == 2:
+            self.intensity = 0
+        elif code == 3:
+            self.italic = True
+        elif code == 4:
+            self.underline = True
+        elif code == 22:
+            self.intensity = 0
+            self.bold = False
+        elif code == 23:
+            self.italic = False
+        elif code == 24:
+            self.underline = False
+        elif code >= 30 and code <= 37:
+            self.foreground_color = code - 30
+        elif code == 39:
+            self.foreground_color = None
+        elif code >= 40 and code <= 47:
+            self.background_color = code - 40
+        elif code == 49:
+            self.background_color = None
+        
+
+class QtAnsiCodeProcessor(AnsiCodeProcessor):
+    """ Translates ANSI escape codes into QTextCharFormats.
+    """
+
+    # A map from color codes to RGB colors.
+    ansi_colors = ( # Normal, Bright/Light
+                   ('#000000', '#7f7f7f'), # 0: black
+                   ('#cd0000', '#ff0000'), # 1: red
+                   ('#00cd00', '#00ff00'), # 2: green
+                   ('#cdcd00', '#ffff00'), # 3: yellow
+                   ('#0000ee', '#0000ff'), # 4: blue
+                   ('#cd00cd', '#ff00ff'), # 5: magenta
+                   ('#00cdcd', '#00ffff'), # 6: cyan
+                   ('#e5e5e5', '#ffffff')) # 7: white
+    
+    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.ansi_colors[self.foreground_color][self.intensity]
+            format.setForeground(QtGui.QColor(color))
+
+        # Set background color
+        if self.background_color is not None:
+            color = self.ansi_colors[self.background_color][self.intensity]
+            format.setBackground(QtGui.QColor(color))
+
+        # Set font weight/style options
+        if self.bold:
+            format.setFontWeight(QtGui.QFont.Bold)
+        else:
+            format.setFontWeight(QtGui.QFont.Normal)
+        format.setFontItalic(self.italic)
+        format.setFontUnderline(self.underline)
+
+        return format
diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py
index 907902e..f5044a6 100644
--- a/IPython/frontend/qt/console/console_widget.py
+++ b/IPython/frontend/qt/console/console_widget.py
@@ -1,99 +1,14 @@
 # Standard library imports
-import re
 import sys
 
 # System library imports
 from PyQt4 import QtCore, QtGui
 
 # Local imports
+from ansi_code_processor import QtAnsiCodeProcessor
 from completion_widget import CompletionWidget
 
 
-class AnsiCodeProcessor(object):
-    """ Translates ANSI escape codes into readable attributes.
-    """
-
-    def __init__(self):
-        self.ansi_colors = ( # Normal, Bright/Light
-                             ('#000000', '#7f7f7f'), # 0: black
-                             ('#cd0000', '#ff0000'), # 1: red
-                             ('#00cd00', '#00ff00'), # 2: green
-                             ('#cdcd00', '#ffff00'), # 3: yellow
-                             ('#0000ee', '#0000ff'), # 4: blue
-                             ('#cd00cd', '#ff00ff'), # 5: magenta
-                             ('#00cdcd', '#00ffff'), # 6: cyan
-                             ('#e5e5e5', '#ffffff')) # 7: white
-        self.reset()
-        
-    def set_code(self, code):
-        """ Set attributes based on code.
-        """
-        if code == 0:
-            self.reset()
-        elif code == 1:
-            self.intensity = 1
-            self.bold = True
-        elif code == 3:
-            self.italic = True
-        elif code == 4:
-            self.underline = True
-        elif code == 22:
-            self.intensity = 0
-            self.bold = False
-        elif code == 23:
-            self.italic = False
-        elif code == 24:
-            self.underline = False
-        elif code >= 30 and code <= 37:
-            self.foreground_color = code - 30
-        elif code == 39:
-            self.foreground_color = None
-        elif code >= 40 and code <= 47:
-            self.background_color = code - 40
-        elif code == 49:
-            self.background_color = None
-        
-    def reset(self):
-        """ Reset attributs to their default values.
-        """
-        self.intensity = 0
-        self.italic = False
-        self.bold = False
-        self.underline = False
-        self.foreground_color = None
-        self.background_color = None
-
-
-class QtAnsiCodeProcessor(AnsiCodeProcessor):
-    """ Translates ANSI escape codes into QTextCharFormats.
-    """
-    
-    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.ansi_colors[self.foreground_color][self.intensity]
-            format.setForeground(QtGui.QColor(color))
-
-        # Set background color
-        if self.background_color is not None:
-            color = self.ansi_colors[self.background_color][self.intensity]
-            format.setBackground(QtGui.QColor(color))
-
-        # Set font weight/style options
-        if self.bold:
-            format.setFontWeight(QtGui.QFont.Bold)
-        else:
-            format.setFontWeight(QtGui.QFont.Normal)
-        format.setFontItalic(self.italic)
-        format.setFontUnderline(self.underline)
-
-        return format
-
-
 class ConsoleWidget(QtGui.QPlainTextEdit):
     """ Base class for console-type widgets. This class is mainly concerned with
         dealing with the prompt, keeping the cursor inside the editing line, and
@@ -114,8 +29,10 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
     # priority (when it has focus) over, e.g., window-level menu shortcuts.
     override_shortcuts = False
 
+    # The number of spaces to show for a tab character.
+    tab_width = 4
+
     # Protected class variables.
-    _ansi_pattern = re.compile('\x01?\x1b\[(.*?)m\x02?')
     _ctrl_down_remap = { QtCore.Qt.Key_B : QtCore.Qt.Key_Left,
                          QtCore.Qt.Key_F : QtCore.Qt.Key_Right,
                          QtCore.Qt.Key_A : QtCore.Qt.Key_Home,
@@ -375,15 +292,9 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
         """
         cursor = self._get_end_cursor()
         if self.ansi_codes:
-            format = QtGui.QTextCharFormat()
-            previous_end = 0
-            for match in self._ansi_pattern.finditer(text):
-                cursor.insertText(text[previous_end:match.start()], format)
-                previous_end = match.end()
-                for code in match.group(1).split(';'):
-                    self._ansi_processor.set_code(int(code))
+            for substring in self._ansi_processor.split_string(text):
                 format = self._ansi_processor.get_format()
-            cursor.insertText(text[previous_end:], format)
+                cursor.insertText(substring, format)
         else:
             cursor.insertText(text)
 
@@ -531,6 +442,9 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
     def _set_font(self, font):
         """ Sets the base font for the ConsoleWidget to the specified QFont.
         """
+        font_metrics = QtGui.QFontMetrics(font)
+        self.setTabStopWidth(self.tab_width * font_metrics.width(' '))
+
         self._completion_widget.setFont(font)
         self.document().setDefaultFont(font)
 
diff --git a/IPython/frontend/qt/console/frontend_widget.py b/IPython/frontend/qt/console/frontend_widget.py
index 7890f07..9736e2e 100644
--- a/IPython/frontend/qt/console/frontend_widget.py
+++ b/IPython/frontend/qt/console/frontend_widget.py
@@ -118,7 +118,7 @@ class FrontendWidget(HistoryConsoleWidget):
             prompt created. When triggered by an Enter/Return key press,
             'interactive' is True; otherwise, it is False.
         """
-        complete = self._input_splitter.push(source)
+        complete = self._input_splitter.push(source.replace('\t', '    '))
         if interactive:
             complete = not self._input_splitter.push_accepts_more()
         return complete
@@ -138,7 +138,8 @@ class FrontendWidget(HistoryConsoleWidget):
             # Auto-indent if this is a continuation prompt.
             if self._get_prompt_cursor().blockNumber() != \
                     self._get_end_cursor().blockNumber():
-                self.appendPlainText(' ' * self._input_splitter.indent_spaces)
+                spaces = self._input_splitter.indent_spaces
+                self.appendPlainText('\t' * (spaces / 4) + ' ' * (spaces % 4))
 
     def _prompt_finished_hook(self):
         """ Called immediately after a prompt is finished, i.e. when some input
@@ -153,9 +154,7 @@ class FrontendWidget(HistoryConsoleWidget):
         """
         self._keep_cursor_in_buffer()
         cursor = self.textCursor()
-        if not self._complete():
-            cursor.insertText('    ')
-        return False
+        return not self._complete()
 
     #---------------------------------------------------------------------------
     # 'FrontendWidget' interface
@@ -341,7 +340,7 @@ class FrontendWidget(HistoryConsoleWidget):
         self.appendPlainText(omsg['content']['data'])
         self.moveCursor(QtGui.QTextCursor.End)
         
-    def _handle_execute_reply(self, rep):
+    def _handle_execute_reply(self, reply):
         if self._hidden:
             return
 
@@ -349,16 +348,20 @@ class FrontendWidget(HistoryConsoleWidget):
         # before writing a new prompt.
         self.kernel_manager.sub_channel.flush()
 
-        content = rep['content']
-        status = content['status']
+        status = reply['content']['status']
         if status == 'error':
-            self.appendPlainText(content['traceback'][-1])
+            self._handle_execute_error(reply)
         elif status == 'aborted':
             text = "ERROR: ABORTED\n"
             self.appendPlainText(text)
         self._hidden = True
         self._show_interpreter_prompt()
-        self.executed.emit(rep)
+        self.executed.emit(reply)
+
+    def _handle_execute_error(self, reply):
+        content = reply['content']
+        traceback = ''.join(content['traceback'])
+        self.appendPlainText(traceback)
 
     def _handle_complete_reply(self, rep):
         cursor = self.textCursor()
diff --git a/IPython/frontend/qt/console/ipython_widget.py b/IPython/frontend/qt/console/ipython_widget.py
index 1b950b2..ec34d66 100644
--- a/IPython/frontend/qt/console/ipython_widget.py
+++ b/IPython/frontend/qt/console/ipython_widget.py
@@ -12,6 +12,7 @@ class IPythonWidget(FrontendWidget):
 
     # The default stylesheet for prompts, colors, etc.
     default_stylesheet = """
+        .error { color: red; }
         .in-prompt { color: navy; }
         .in-prompt-number { font-weight: bold; }
         .out-prompt { color: darkred; }
@@ -90,6 +91,21 @@ class IPythonWidget(FrontendWidget):
 
     #------ Signal handlers ----------------------------------------------------
 
+    def _handle_execute_error(self, reply):
+        """ Reimplemented for IPython-style traceback formatting.
+        """
+        content = reply['content']
+        traceback_lines = content['traceback'][:]
+        traceback = ''.join(traceback_lines)
+        traceback = traceback.replace(' ', '&nbsp;')
+        traceback = traceback.replace('\n', '<br/>')
+
+        ename = content['ename']
+        ename_styled = '<span class="error">%s</span>' % ename
+        traceback = traceback.replace(ename, ename_styled)
+
+        self.appendHtml(traceback)
+
     def _handle_pyout(self, omsg):
         """ Reimplemented for IPython-style "display hook".
         """
diff --git a/IPython/frontend/qt/console/tests/test_ansi_code_processor.py b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
new file mode 100644
index 0000000..cad76dc
--- /dev/null
+++ b/IPython/frontend/qt/console/tests/test_ansi_code_processor.py
@@ -0,0 +1,32 @@
+# Standard library imports
+import unittest
+
+# Local imports
+from IPython.frontend.qt.console.ansi_code_processor import AnsiCodeProcessor
+
+
+class TestAnsiCodeProcessor(unittest.TestCase):
+
+    def setUp(self):
+        self.processor = AnsiCodeProcessor()
+
+    def testColors(self):
+        string = "first\x1b[34mblue\x1b[0mlast"
+        i = -1
+        for i, substring in enumerate(self.processor.split_string(string)):
+            if i == 0:
+                self.assertEquals(substring, 'first')
+                self.assertEquals(self.processor.foreground_color, None)
+            elif i == 1:
+                self.assertEquals(substring, 'blue')
+                self.assertEquals(self.processor.foreground_color, 4)
+            elif i == 2:
+                self.assertEquals(substring, 'last')
+                self.assertEquals(self.processor.foreground_color, None)
+            else:
+                self.fail("Too many substrings.")
+        self.assertEquals(i, 2, "Too few substrings.")
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/IPython/zmq/kernel.py b/IPython/zmq/kernel.py
index 4fb4f49..2688816 100755
--- a/IPython/zmq/kernel.py
+++ b/IPython/zmq/kernel.py
@@ -268,7 +268,7 @@ class Kernel(object):
             exc_content = {
                 u'status' : u'error',
                 u'traceback' : tb,
-                u'etype' : unicode(etype),
+                u'ename' : unicode(etype.__name__),
                 u'evalue' : unicode(evalue)
             }
             exc_msg = self.session.msg(u'pyerr', exc_content, parent)