From 96d379e0e4d675dc7451bf33464ac3bea1dbc16a 2010-08-06 17:53:50
From: epatters <epatters@enthought.com>
Date: 2010-08-06 17:53:50
Subject: [PATCH] * IPythonWidget now has IPython-style prompts that are futher stylabla via CSS
* Added support for HTML to ConsoleWidget
* General cleanup and refactoring.

---

diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py
index 79ef1d0..907902e 100644
--- a/IPython/frontend/qt/console/console_widget.py
+++ b/IPython/frontend/qt/console/console_widget.py
@@ -133,12 +133,15 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
     def __init__(self, parent=None):
         QtGui.QPlainTextEdit.__init__(self, parent)
 
-        # Initialize protected variables.
+        # Initialize protected variables. Some variables contain useful state
+        # information for subclasses; they should be considered read-only.
         self._ansi_processor = QtAnsiCodeProcessor()
         self._completion_widget = CompletionWidget(self)
         self._continuation_prompt = '> '
+        self._continuation_prompt_html = None
         self._executing = False
         self._prompt = ''
+        self._prompt_html = None
         self._prompt_pos = 0
         self._reading = False
         self._reading_callback = None
@@ -343,14 +346,34 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
     # 'QPlainTextEdit' interface
     #--------------------------------------------------------------------------
 
+    def appendHtml(self, html):
+        """ Reimplemented to not append HTML as a new paragraph, which doesn't
+            make sense for a console widget.
+        """
+        cursor = self._get_end_cursor()
+        cursor.insertHtml(html)
+
+        # After appending HTML, the text document "remembers" the current
+        # formatting, which means that subsequent calls to 'appendPlainText'
+        # will be formatted similarly, a behavior that we do not want. To
+        # prevent this, we make sure that the last character has no formatting.
+        cursor.movePosition(QtGui.QTextCursor.Left, 
+                            QtGui.QTextCursor.KeepAnchor)
+        if cursor.selection().toPlainText().trimmed().isEmpty():
+            # If the last character is whitespace, it doesn't matter how it's
+            # formatted, so just clear the formatting.
+            cursor.setCharFormat(QtGui.QTextCharFormat())
+        else:
+            # Otherwise, add an unformatted space.
+            cursor.movePosition(QtGui.QTextCursor.Right)
+            cursor.insertText(' ', QtGui.QTextCharFormat())
+
     def appendPlainText(self, text):
         """ Reimplemented to not append text as a new paragraph, which doesn't
             make sense for a console widget. Also, if enabled, handle ANSI
             codes.
         """
-        cursor = self.textCursor()
-        cursor.movePosition(QtGui.QTextCursor.End)
-
+        cursor = self._get_end_cursor()
         if self.ansi_codes:
             format = QtGui.QTextCharFormat()
             previous_end = 0
@@ -365,18 +388,15 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
             cursor.insertText(text)
 
     def clear(self, keep_input=False):
-        """ Reimplemented to cancel reading and write a new prompt. If
-            'keep_input' is set, restores the old input buffer when the new
-            prompt is written.
+        """ Reimplemented to write a new prompt. If 'keep_input' is set,
+            restores the old input buffer when the new prompt is written.
         """
         QtGui.QPlainTextEdit.clear(self)
-        input_buffer = ''
-        if self._reading:
-            self._reading = False
-        elif keep_input:
+        if keep_input:
             input_buffer = self.input_buffer
         self._show_prompt()
-        self.input_buffer = input_buffer
+        if keep_input:
+            self.input_buffer = input_buffer
 
     def paste(self):
         """ Reimplemented to ensure that text is pasted in the editing region.
@@ -463,9 +483,6 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
 
         cursor = self._get_end_cursor()
         cursor.setPosition(self._prompt_pos, QtGui.QTextCursor.KeepAnchor)
-
-        # Use QTextDocumentFragment intermediate object because it strips
-        # out the Unicode line break characters that Qt insists on inserting.
         input_buffer = str(cursor.selection().toPlainText())
 
         # Strip out continuation prompts.
@@ -496,7 +513,7 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
             return None
         cursor = self.textCursor()
         if cursor.position() >= self._prompt_pos:
-            text = str(cursor.block().text())
+            text = self._get_block_plain_text(cursor.block())
             if cursor.blockNumber() == self._get_prompt_cursor().blockNumber():
                 return text[len(self._prompt):]
             else:
@@ -581,6 +598,27 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
     # 'ConsoleWidget' protected interface
     #--------------------------------------------------------------------------
 
+    def _append_html_fetching_plain_text(self, html):
+        """ Appends 'html', then returns the plain text version of it.
+        """
+        anchor = self._get_end_cursor().position()
+        self.appendHtml(html)
+        cursor = self._get_end_cursor()
+        cursor.setPosition(anchor, QtGui.QTextCursor.KeepAnchor)
+        return str(cursor.selection().toPlainText())
+
+    def _append_plain_text_keeping_prompt(self, text):
+        """ Writes 'text' after the current prompt, then restores the old prompt
+            with its old input buffer.
+        """
+        input_buffer = self.input_buffer
+        self.appendPlainText('\n')
+        self._prompt_finished()
+
+        self.appendPlainText(text)
+        self._show_prompt()
+        self.input_buffer = input_buffer
+
     def _control_down(self, modifiers):
         """ Given a KeyboardModifiers flags object, return whether the Control
             key is down (on Mac OS, treat the Command key as a synonym for
@@ -607,7 +645,16 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
                 self._completion_widget.show_items(cursor, items) 
             else:
                 text = '\n'.join(items) + '\n'
-                self._write_text_keeping_prompt(text)
+                self._append_plain_text_keeping_prompt(text)
+
+    def _get_block_plain_text(self, block):
+        """ Given a QTextBlock, return its unformatted text.
+        """
+        cursor = QtGui.QTextCursor(block)
+        cursor.movePosition(QtGui.QTextCursor.StartOfBlock)
+        cursor.movePosition(QtGui.QTextCursor.EndOfBlock, 
+                            QtGui.QTextCursor.KeepAnchor)
+        return str(cursor.selection().toPlainText())
                 
     def _get_end_cursor(self):
         """ Convenience method that returns a cursor for the last character.
@@ -728,6 +775,31 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
             self._reading_callback = lambda: \
                 callback(self.input_buffer.rstrip('\n'))
 
+    def _reset(self):
+        """ Clears the console and resets internal state variables.
+        """
+        QtGui.QPlainTextEdit.clear(self)
+        self._executing = self._reading = False
+
+    def _set_continuation_prompt(self, prompt, html=False):
+        """ Sets the continuation prompt.
+
+        Parameters
+        ----------
+        prompt : str
+            The prompt to show when more input is needed.
+
+        html : bool, optional (default False)
+            If set, the prompt will be inserted as formatted HTML. Otherwise,
+            the prompt will be treated as plain text, though ANSI color codes
+            will be handled.
+        """
+        if html:
+            self._continuation_prompt_html = prompt
+        else:
+            self._continuation_prompt = prompt
+            self._continuation_prompt_html = None
+
     def _set_position(self, position):
         """ Convenience method to set the position of the cursor.
         """
@@ -740,7 +812,7 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
         """
         self.setTextCursor(self._get_selection_cursor(start, end))
 
-    def _show_prompt(self, prompt=None, newline=True):
+    def _show_prompt(self, prompt=None, html=False, newline=True):
         """ Writes a new prompt at the end of the buffer.
 
         Parameters
@@ -748,10 +820,16 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
         prompt : str, optional
             The prompt to show. If not specified, the previous prompt is used.
 
+        html : bool, optional (default False)
+            Only relevant when a prompt is specified. If set, the prompt will
+            be inserted as formatted HTML. Otherwise, the prompt will be treated
+            as plain text, though ANSI color codes will be handled.
+
         newline : bool, optional (default True)
             If set, a new line will be written before showing the prompt if 
             there is not already a newline at the end of the buffer.
         """
+        # Insert a preliminary newline, if necessary.
         if newline:
             cursor = self._get_end_cursor()
             if cursor.position() > 0:
@@ -760,9 +838,20 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
                 if str(cursor.selection().toPlainText()) != '\n':
                     self.appendPlainText('\n')
 
-        if prompt is not None:
-            self._prompt = prompt
-        self.appendPlainText(self._prompt)
+        # Write the prompt.
+        if prompt is None:
+            if self._prompt_html is None:
+                self.appendPlainText(self._prompt)
+            else:
+                self.appendHtml(self._prompt_html)
+        else:
+            if html:
+                self._prompt = self._append_html_fetching_plain_text(prompt)
+                self._prompt_html = prompt
+            else:
+                self.appendPlainText(prompt)
+                self._prompt = prompt
+                self._prompt_html = None
 
         self._prompt_pos = self._get_end_cursor().position()
         self._prompt_started()
@@ -770,20 +859,13 @@ class ConsoleWidget(QtGui.QPlainTextEdit):
     def _show_continuation_prompt(self):
         """ Writes a new continuation prompt at the end of the buffer.
         """
-        self.appendPlainText(self._continuation_prompt)
-        self._prompt_started()
-
-    def _write_text_keeping_prompt(self, text):
-        """ Writes 'text' after the current prompt, then restores the old prompt
-            with its old input buffer.
-        """
-        input_buffer = self.input_buffer
-        self.appendPlainText('\n')
-        self._prompt_finished()
+        if self._continuation_prompt_html is None:
+            self.appendPlainText(self._continuation_prompt)
+        else:
+            self._continuation_prompt = self._append_html_fetching_plain_text(
+                self._continuation_prompt_html)
 
-        self.appendPlainText(text)
-        self._show_prompt()
-        self.input_buffer = input_buffer
+        self._prompt_started()
 
     def _in_buffer(self, position):
         """ Returns whether the given position is inside the editing region.
diff --git a/IPython/frontend/qt/console/frontend_widget.py b/IPython/frontend/qt/console/frontend_widget.py
index db1ab48..7890f07 100644
--- a/IPython/frontend/qt/console/frontend_widget.py
+++ b/IPython/frontend/qt/console/frontend_widget.py
@@ -16,12 +16,12 @@ from pygments_highlighter import PygmentsHighlighter
 
 
 class FrontendHighlighter(PygmentsHighlighter):
-    """ A Python PygmentsHighlighter that can be turned on and off and which 
-        knows about continuation prompts.
+    """ A PygmentsHighlighter that can be turned on and off and that ignores
+        prompts.
     """
 
     def __init__(self, frontend):
-        PygmentsHighlighter.__init__(self, frontend.document(), PythonLexer())
+        super(FrontendHighlighter, self).__init__(frontend.document())
         self._current_offset = 0
         self._frontend = frontend
         self.highlighting_on = False
@@ -29,17 +29,32 @@ class FrontendHighlighter(PygmentsHighlighter):
     def highlightBlock(self, qstring):
         """ Highlight a block of text. Reimplemented to highlight selectively.
         """
-        if self.highlighting_on:
-            for prompt in (self._frontend._continuation_prompt,
-                           self._frontend._prompt):                           
-                if qstring.startsWith(prompt):
-                    qstring.remove(0, len(prompt))
-                    self._current_offset = len(prompt)
-                    break
-            PygmentsHighlighter.highlightBlock(self, qstring)
+        if not self.highlighting_on:
+            return
+
+        # The input to this function is unicode string that may contain
+        # paragraph break characters, non-breaking spaces, etc. Here we acquire
+        # the string as plain text so we can compare it.
+        current_block = self.currentBlock()
+        string = self._frontend._get_block_plain_text(current_block)
+
+        # Decide whether to check for the regular or continuation prompt.
+        if current_block.contains(self._frontend._prompt_pos):
+            prompt = self._frontend._prompt
+        else:
+            prompt = self._frontend._continuation_prompt
+
+        # Don't highlight the part of the string that contains the prompt.
+        if string.startswith(prompt):
+            self._current_offset = len(prompt)
+            qstring.remove(0, len(prompt))
+        else:
+            self._current_offset = 0
+
+        PygmentsHighlighter.highlightBlock(self, qstring)
 
     def setFormat(self, start, count, format):
-        """ Reimplemented to avoid highlighting continuation prompts.
+        """ Reimplemented to highlight selectively.
         """
         start += self._current_offset
         PygmentsHighlighter.setFormat(self, start, count, format)
@@ -59,9 +74,6 @@ class FrontendWidget(HistoryConsoleWidget):
     def __init__(self, parent=None):
         super(FrontendWidget, self).__init__(parent)
 
-        # ConsoleWidget protected variables.
-        self._continuation_prompt = '... '
-
         # FrontendWidget protected variables.
         self._call_tip_widget = CallTipWidget(self)
         self._completion_lexer = CompletionLexer(PythonLexer())
@@ -70,6 +82,9 @@ class FrontendWidget(HistoryConsoleWidget):
         self._input_splitter = InputSplitter(input_mode='replace')
         self._kernel_manager = None
 
+        # Configure the ConsoleWidget.
+        self._set_continuation_prompt('... ')
+
         self.document().contentsChange.connect(self._document_contents_change)
 
     #---------------------------------------------------------------------------
@@ -143,17 +158,6 @@ class FrontendWidget(HistoryConsoleWidget):
         return False
 
     #---------------------------------------------------------------------------
-    # 'ConsoleWidget' protected interface
-    #---------------------------------------------------------------------------
-
-    def _show_prompt(self, prompt=None, newline=True):
-        """ Reimplemented to set a default prompt.
-        """
-        if prompt is None:
-            prompt = '>>> '
-        super(FrontendWidget, self)._show_prompt(prompt, newline)
-
-    #---------------------------------------------------------------------------
     # 'FrontendWidget' interface
     #---------------------------------------------------------------------------
 
@@ -283,20 +287,24 @@ class FrontendWidget(HistoryConsoleWidget):
             self.appendPlainText('Kernel process is either remote or '
                                  'unspecified. Cannot interrupt.\n')
 
+    def _show_interpreter_prompt(self):
+        """ Shows a prompt for the interpreter.
+        """
+        self._show_prompt('>>> ')
+
     #------ Signal handlers ----------------------------------------------------
 
     def _started_channels(self):
         """ Called when the kernel manager has started listening.
         """
-        QtGui.QPlainTextEdit.clear(self)
-        if self._reading:
-            self._reading = False
+        self._reset()
         self.appendPlainText(self._get_banner())
-        self._show_prompt()
+        self._show_interpreter_prompt()
 
     def _stopped_channels(self):
         """ Called when the kernel manager has stopped listening.
         """
+        # FIXME: Print a message here?
         pass
 
     def _document_contents_change(self, position, removed, added):
@@ -327,9 +335,7 @@ class FrontendWidget(HistoryConsoleWidget):
             handler(omsg)
 
     def _handle_pyout(self, omsg):
-        session = omsg['parent_header']['session']
-        if session == self.kernel_manager.session.session:
-            self.appendPlainText(omsg['content']['data'] + '\n')
+        self.appendPlainText(omsg['content']['data'] + '\n')
 
     def _handle_stream(self, omsg):
         self.appendPlainText(omsg['content']['data'])
@@ -351,7 +357,7 @@ class FrontendWidget(HistoryConsoleWidget):
             text = "ERROR: ABORTED\n"
             self.appendPlainText(text)
         self._hidden = True
-        self._show_prompt()
+        self._show_interpreter_prompt()
         self.executed.emit(rep)
 
     def _handle_complete_reply(self, rep):
diff --git a/IPython/frontend/qt/console/ipython_widget.py b/IPython/frontend/qt/console/ipython_widget.py
index ee08223..1b950b2 100644
--- a/IPython/frontend/qt/console/ipython_widget.py
+++ b/IPython/frontend/qt/console/ipython_widget.py
@@ -10,6 +10,14 @@ class IPythonWidget(FrontendWidget):
     """ A FrontendWidget for an IPython kernel.
     """
 
+    # The default stylesheet for prompts, colors, etc.
+    default_stylesheet = """
+        .in-prompt { color: navy; }
+        .in-prompt-number { font-weight: bold; }
+        .out-prompt { color: darkred; }
+        .out-prompt-number { font-weight: bold; }
+    """
+
     #---------------------------------------------------------------------------
     # 'QObject' interface
     #---------------------------------------------------------------------------
@@ -17,7 +25,12 @@ class IPythonWidget(FrontendWidget):
     def __init__(self, parent=None):
         super(IPythonWidget, self).__init__(parent)
 
+        # Initialize protected variables.
         self._magic_overrides = {}
+        self._prompt_count = 0
+
+        # Set a default stylesheet.
+        self.set_style_sheet(self.default_stylesheet)
 
     #---------------------------------------------------------------------------
     # 'ConsoleWidget' abstract interface
@@ -38,7 +51,7 @@ class IPythonWidget(FrontendWidget):
             output = callback(arguments)
             if output:
                 self.appendPlainText(output)
-            self._show_prompt()
+            self._show_interpreter_prompt()
         else:
             super(IPythonWidget, self)._execute(source, hidden)
 
@@ -56,10 +69,36 @@ class IPythonWidget(FrontendWidget):
     #---------------------------------------------------------------------------
 
     def _get_banner(self):
-        """ Reimplemented to a return IPython's default banner.
+        """ Reimplemented to return IPython's default banner.
         """
         return default_banner
 
+    def _show_interpreter_prompt(self):
+        """ Reimplemented for IPython-style prompts.
+        """
+        self._prompt_count += 1
+        prompt_template = '<span class="in-prompt">%s</span>'
+        prompt_body = '<br/>In [<span class="in-prompt-number">%i</span>]: '
+        prompt = (prompt_template % prompt_body) % self._prompt_count
+        self._show_prompt(prompt, html=True)
+
+        # Update continuation prompt to reflect (possibly) new prompt length.
+        cont_prompt_chars = '...: '
+        space_count = len(self._prompt.lstrip()) - len(cont_prompt_chars)
+        cont_prompt_body = '&nbsp;' * space_count + cont_prompt_chars
+        self._continuation_prompt_html = prompt_template % cont_prompt_body
+
+    #------ Signal handlers ----------------------------------------------------
+
+    def _handle_pyout(self, omsg):
+        """ Reimplemented for IPython-style "display hook".
+        """
+        prompt_template = '<span class="out-prompt">%s</span>'
+        prompt_body = 'Out[<span class="out-prompt-number">%i</span>]: '
+        prompt = (prompt_template % prompt_body) % self._prompt_count
+        self.appendHtml(prompt)
+        self.appendPlainText(omsg['content']['data'] + '\n')
+
     #---------------------------------------------------------------------------
     # 'IPythonWidget' interface
     #---------------------------------------------------------------------------
@@ -81,6 +120,11 @@ class IPythonWidget(FrontendWidget):
         except KeyError:
             pass
 
+    def set_style_sheet(self, stylesheet):
+        """ Sets the style sheet.
+        """
+        self.document().setDefaultStyleSheet(stylesheet)
+
 
 if __name__ == '__main__':
     from IPython.frontend.qt.kernelmanager import QtKernelManager
diff --git a/IPython/frontend/qt/console/pygments_highlighter.py b/IPython/frontend/qt/console/pygments_highlighter.py
index 51197a8..4b45dc9 100644
--- a/IPython/frontend/qt/console/pygments_highlighter.py
+++ b/IPython/frontend/qt/console/pygments_highlighter.py
@@ -1,7 +1,7 @@
 # System library imports.
 from PyQt4 import QtGui
 from pygments.lexer import RegexLexer, _TokenType, Text, Error
-from pygments.lexers import CLexer, CppLexer, PythonLexer
+from pygments.lexers import PythonLexer
 from pygments.styles.default import DefaultStyle
 from pygments.token import Comment
 
@@ -133,7 +133,7 @@ class PygmentsHighlighter(QtGui.QSyntaxHighlighter):
         if token in self._formats:
             return self._formats[token]
         result = None
-        for key, value in self._style.style_for_token(token) .items():
+        for key, value in self._style.style_for_token(token).items():
             if value:
                 if result is None:
                     result = QtGui.QTextCharFormat()
@@ -171,12 +171,11 @@ class PygmentsHighlighter(QtGui.QSyntaxHighlighter):
             qcolor = self._get_color(color)
             result = QtGui.QBrush(qcolor)
             self._brushes[color] = result
-
         return result
 
     def _get_color(self, color):
         qcolor = QtGui.QColor()
-        qcolor.setRgb(int(color[:2],base=16),
+        qcolor.setRgb(int(color[:2], base=16),
                       int(color[2:4], base=16),
                       int(color[4:6], base=16))
         return qcolor