# encoding: utf-8
"""
A Wx widget to act as a console and input commands.

This widget deals with prompts and provides an edit buffer
restricted to after the last prompt.
"""

__docformat__ = "restructuredtext en"

#-------------------------------------------------------------------------------
#  Copyright (C) 2008  The IPython Development Team
#
#  Distributed under the terms of the BSD License.  The full license is
#  in the file COPYING, distributed as part of this software.
#-------------------------------------------------------------------------------

#-------------------------------------------------------------------------------
# Imports
#-------------------------------------------------------------------------------

import wx
import wx.stc  as  stc

from wx.py import editwindow
import time
import sys
import string

LINESEP = '\n'
if sys.platform == 'win32':
    LINESEP = '\n\r'

import re

# FIXME: Need to provide an API for non user-generated display on the
# screen: this should not be editable by the user.
#-------------------------------------------------------------------------------
# Constants 
#-------------------------------------------------------------------------------
_COMPLETE_BUFFER_MARKER = 31
_ERROR_MARKER = 30
_INPUT_MARKER = 29

_DEFAULT_SIZE = 10
if sys.platform == 'darwin':
    _DEFAULT_SIZE = 12

_DEFAULT_STYLE = {
    #background definition
    'default'     : 'size:%d' % _DEFAULT_SIZE,
    'bracegood'   : 'fore:#00AA00,back:#000000,bold',
    'bracebad'    : 'fore:#FF0000,back:#000000,bold',

    # Edge column: a number of None
    'edge_column' : -1,

    # properties for the various Python lexer styles
    'comment'       : 'fore:#007F00',
    'number'        : 'fore:#007F7F',
    'string'        : 'fore:#7F007F,italic',
    'char'          : 'fore:#7F007F,italic',
    'keyword'       : 'fore:#00007F,bold',
    'triple'        : 'fore:#7F0000',
    'tripledouble'  : 'fore:#7F0000',
    'class'         : 'fore:#0000FF,bold,underline',
    'def'           : 'fore:#007F7F,bold',
    'operator'      : 'bold',

    # Default colors
    'trace'         : '#FAFAF1', # Nice green
    'stdout'        : '#FDFFD3', # Nice yellow
    'stderr'        : '#FFF1F1', # Nice red

    # Default scintilla settings
    'antialiasing'  : True,
    'carret_color'  : 'BLACK',
    'background_color' :'WHITE', 

    #prompt definition
    'prompt_in1'    : \
        '\n\x01\x1b[0;34m\x02In [\x01\x1b[1;34m\x02$number\x01\x1b[0;34m\x02]: \x01\x1b[0m\x02',

    'prompt_out': \
        '\x01\x1b[0;31m\x02Out[\x01\x1b[1;31m\x02$number\x01\x1b[0;31m\x02]: \x01\x1b[0m\x02',
    }

# new style numbers
_STDOUT_STYLE = 15
_STDERR_STYLE = 16
_TRACE_STYLE  = 17


# system colors
#SYS_COLOUR_BACKGROUND = wx.SystemSettings.GetColour(wx.SYS_COLOUR_BACKGROUND)

# Translation table from ANSI escape sequences to color. 
ANSI_STYLES = {'0;30': [0, 'BLACK'],            '0;31': [1, 'RED'],
               '0;32': [2, 'GREEN'],           '0;33': [3, 'BROWN'],
               '0;34': [4, 'BLUE'],            '0;35': [5, 'PURPLE'],
               '0;36': [6, 'CYAN'],            '0;37': [7, 'LIGHT GREY'],
               '1;30': [8, 'DARK GREY'],       '1;31': [9, 'RED'],
               '1;32': [10, 'SEA GREEN'],      '1;33': [11, 'YELLOW'],
               '1;34': [12, 'LIGHT BLUE'],     '1;35': 
                                                 [13, 'MEDIUM VIOLET RED'],
               '1;36': [14, 'LIGHT STEEL BLUE'], '1;37': [15, 'YELLOW']}

# XXX: Maybe one day we should factor this code with coloransi. Right now
# coloransi is hard to reuse and makes our code more complex.

#we define platform specific fonts
if wx.Platform == '__WXMSW__':
    FACES = { 'times': 'Times New Roman',
                'mono' : 'Courier New',
                'helv' : 'Arial',
                'other': 'Comic Sans MS',
                'size' : 10,
                'size2': 8,
                }
elif wx.Platform == '__WXMAC__':
    FACES = { 'times': 'Times New Roman',
                'mono' : 'Monaco',
                'helv' : 'Arial',
                'other': 'Comic Sans MS',
                'size' : 10,
                'size2': 8,
                }
else:
    FACES = { 'times': 'Times',
                'mono' : 'Courier',
                'helv' : 'Helvetica',
                'other': 'new century schoolbook',
                'size' : 10,
                'size2': 8,
                }
 

#-------------------------------------------------------------------------------
# The console widget class
#-------------------------------------------------------------------------------
class ConsoleWidget(editwindow.EditWindow):
    """ Specialized styled text control view for console-like workflow.

        This widget is mainly interested in dealing with the prompt and
        keeping the cursor inside the editing line.
    """

    # This is where the title captured from the ANSI escape sequences are
    # stored.
    title = 'Console'

    # Last prompt printed
    last_prompt = ''

    # The buffer being edited.
    def _set_input_buffer(self, string):
        self.SetSelection(self.current_prompt_pos, self.GetLength())
        self.ReplaceSelection(string)
        self.GotoPos(self.GetLength())

    def _get_input_buffer(self):
        """ Returns the text in current edit buffer.
        """
        input_buffer = self.GetTextRange(self.current_prompt_pos,
                                                self.GetLength())
        input_buffer = input_buffer.replace(LINESEP, '\n')
        return input_buffer

    input_buffer = property(_get_input_buffer, _set_input_buffer)

    style = _DEFAULT_STYLE.copy()

    # Translation table from ANSI escape sequences to color. Override
    # this to specify your colors.
    ANSI_STYLES = ANSI_STYLES.copy()
    
    # Font faces
    faces = FACES.copy()
                 
    # Store the last time a refresh was done
    _last_refresh_time = 0

    #--------------------------------------------------------------------------
    # Public API
    #--------------------------------------------------------------------------
    
    def __init__(self, parent, id=wx.ID_ANY, pos=wx.DefaultPosition, 
                        size=wx.DefaultSize, style=wx.WANTS_CHARS, ):
        editwindow.EditWindow.__init__(self, parent, id, pos, size, style)
        self.configure_scintilla()
        # Track if 'enter' key as ever been processed
        # This variable will only be reallowed until key goes up
        self.enter_catched = False 
        self.current_prompt_pos = 0

        self.Bind(wx.EVT_KEY_DOWN, self._on_key_down)
        self.Bind(wx.EVT_KEY_UP, self._on_key_up)
    

    def write(self, text, refresh=True):
        """ Write given text to buffer, while translating the ansi escape
            sequences.
        """
        # XXX: do not put print statements to sys.stdout/sys.stderr in 
        # this method, the print statements will call this method, as 
        # you will end up with an infinit loop
        title = self.title_pat.split(text)
        if len(title)>1:
            self.title = title[-2]

        text = self.title_pat.sub('', text)
        segments = self.color_pat.split(text)
        segment = segments.pop(0)
        self.GotoPos(self.GetLength())
        self.StartStyling(self.GetLength(), 0xFF)
        try:
            self.AppendText(segment)
        except UnicodeDecodeError:
            # XXX: Do I really want to skip the exception?
            pass
        
        if segments:
            for ansi_tag, text in zip(segments[::2], segments[1::2]):
                self.StartStyling(self.GetLength(), 0xFF)
                try:
                    self.AppendText(text)
                except UnicodeDecodeError:
                    # XXX: Do I really want to skip the exception?
                    pass

                if ansi_tag not in self.ANSI_STYLES:
                    style = 0
                else:
                    style = self.ANSI_STYLES[ansi_tag][0]

                self.SetStyling(len(text), style) 
                
        self.GotoPos(self.GetLength())
        if refresh:
            current_time = time.time()
            if current_time - self._last_refresh_time > 0.03:
                if sys.platform == 'win32':
                    wx.SafeYield()
                else:
                    wx.Yield()
                #    self.ProcessEvent(wx.PaintEvent())
                self._last_refresh_time = current_time 

   
    def new_prompt(self, prompt):
        """ Prints a prompt at start of line, and move the start of the
            current block there.

            The prompt can be given with ascii escape sequences.
        """
        self.write(prompt, refresh=False)
        # now we update our cursor giving end of prompt
        self.current_prompt_pos = self.GetLength()
        self.current_prompt_line = self.GetCurrentLine()
        self.EnsureCaretVisible()
        self.last_prompt = prompt


    def continuation_prompt(self):
        """ Returns the current continuation prompt.
            We need to implement this method here to deal with the
            ascii escape sequences cleaning up.
        """
        # ASCII-less prompt
        ascii_less = ''.join(self.color_pat.split(self.last_prompt)[2::2])
        return "."*(len(ascii_less)-2) + ': '
 

    def scroll_to_bottom(self):
        maxrange = self.GetScrollRange(wx.VERTICAL)
        self.ScrollLines(maxrange)


    def pop_completion(self, possibilities, offset=0):
        """ Pops up an autocompletion menu. Offset is the offset
            in characters of the position at which the menu should
            appear, relativ to the cursor.
        """
        self.AutoCompSetIgnoreCase(False)
        self.AutoCompSetAutoHide(False)
        self.AutoCompSetMaxHeight(len(possibilities))
        self.AutoCompShow(offset, " ".join(possibilities))


    def get_line_width(self):
        """ Return the width of the line in characters.
        """
        return self.GetSize()[0]/self.GetCharWidth()


    def configure_scintilla(self):
        """ Set up all the styling option of the embedded scintilla
            widget.
        """
        p = self.style.copy()
        
        # Marker for complete buffer.
        self.MarkerDefine(_COMPLETE_BUFFER_MARKER, stc.STC_MARK_BACKGROUND,
                                background=p['trace'])

        # Marker for current input buffer.
        self.MarkerDefine(_INPUT_MARKER, stc.STC_MARK_BACKGROUND,
                                background=p['stdout'])
        # Marker for tracebacks.
        self.MarkerDefine(_ERROR_MARKER, stc.STC_MARK_BACKGROUND,
                                background=p['stderr'])

        self.SetEOLMode(stc.STC_EOL_LF)

        # Ctrl"+" or Ctrl "-" can be used to zoomin/zoomout the text inside 
        # the widget
        self.CmdKeyAssign(ord('+'), stc.STC_SCMOD_CTRL, stc.STC_CMD_ZOOMIN)
        self.CmdKeyAssign(ord('-'), stc.STC_SCMOD_CTRL, stc.STC_CMD_ZOOMOUT)
        # Also allow Ctrl Shift "=" for poor non US keyboard users. 
        self.CmdKeyAssign(ord('='), stc.STC_SCMOD_CTRL|stc.STC_SCMOD_SHIFT, 
                                            stc.STC_CMD_ZOOMIN)
        
        # Keys: we need to clear some of the keys the that don't play
        # well with a console.
        self.CmdKeyClear(ord('D'), stc.STC_SCMOD_CTRL)
        self.CmdKeyClear(ord('L'), stc.STC_SCMOD_CTRL)
        self.CmdKeyClear(ord('T'), stc.STC_SCMOD_CTRL)
        self.CmdKeyClear(ord('A'), stc.STC_SCMOD_CTRL)

        self.SetEOLMode(stc.STC_EOL_CRLF)
        self.SetWrapMode(stc.STC_WRAP_CHAR)
        self.SetWrapMode(stc.STC_WRAP_WORD)
        self.SetBufferedDraw(True)

        self.SetUseAntiAliasing(p['antialiasing'])

        self.SetLayoutCache(stc.STC_CACHE_PAGE)
        self.SetUndoCollection(False)
        self.SetUseTabs(True)
        self.SetIndent(4)
        self.SetTabWidth(4)

        # we don't want scintilla's autocompletion to choose 
        # automaticaly out of a single choice list, as we pop it up
        # automaticaly
        self.AutoCompSetChooseSingle(False)
        self.AutoCompSetMaxHeight(10)
        # XXX: this doesn't seem to have an effect.
        self.AutoCompSetFillUps('\n')

        self.SetMargins(3, 3) #text is moved away from border with 3px
        # Suppressing Scintilla margins
        self.SetMarginWidth(0, 0)
        self.SetMarginWidth(1, 0)
        self.SetMarginWidth(2, 0)

        # Xterm escape sequences
        self.color_pat = re.compile('\x01?\x1b\[(.*?)m\x02?')
        self.title_pat = re.compile('\x1b]0;(.*?)\x07')

        # styles
        
        self.SetCaretForeground(p['carret_color'])
        
        background_color = p['background_color']
            
        if 'default' in p:
            if 'back' not in p['default']:
                p['default'] += ',back:%s' % background_color
            if 'size' not in p['default']:
                p['default'] += ',size:%s' % self.faces['size']
            if 'face' not in p['default']:
                p['default'] += ',face:%s' % self.faces['mono']
            
            self.StyleSetSpec(stc.STC_STYLE_DEFAULT, p['default'])
        else:
            self.StyleSetSpec(stc.STC_STYLE_DEFAULT, 
                            "fore:%s,back:%s,size:%d,face:%s" 
                            % (self.ANSI_STYLES['0;30'][1], 
                               background_color,
                               self.faces['size'], self.faces['mono']))
        
        self.StyleClearAll()
        
        # XXX: two lines below are usefull if not using the lexer        
        #for style in self.ANSI_STYLES.values():
        #    self.StyleSetSpec(style[0], "bold,fore:%s" % style[1])        

        # prompt definition
        self.prompt_in1 = p['prompt_in1']
        self.prompt_out = p['prompt_out']

        self.output_prompt_template = string.Template(self.prompt_out)
        self.input_prompt_template = string.Template(self.prompt_in1)

        self.StyleSetSpec(_STDOUT_STYLE, p['stdout'])
        self.StyleSetSpec(_STDERR_STYLE, p['stderr'])
        self.StyleSetSpec(_TRACE_STYLE, p['trace'])
        self.StyleSetSpec(stc.STC_STYLE_BRACELIGHT, p['bracegood'])
        self.StyleSetSpec(stc.STC_STYLE_BRACEBAD, p['bracebad'])
        self.StyleSetSpec(stc.STC_P_COMMENTLINE, p['comment'])
        self.StyleSetSpec(stc.STC_P_NUMBER, p['number'])
        self.StyleSetSpec(stc.STC_P_STRING, p['string'])
        self.StyleSetSpec(stc.STC_P_CHARACTER, p['char'])
        self.StyleSetSpec(stc.STC_P_WORD, p['keyword'])
        self.StyleSetSpec(stc.STC_P_WORD2, p['keyword'])
        self.StyleSetSpec(stc.STC_P_TRIPLE, p['triple'])
        self.StyleSetSpec(stc.STC_P_TRIPLEDOUBLE, p['tripledouble'])
        self.StyleSetSpec(stc.STC_P_CLASSNAME, p['class'])
        self.StyleSetSpec(stc.STC_P_DEFNAME, p['def'])
        self.StyleSetSpec(stc.STC_P_OPERATOR, p['operator'])
        self.StyleSetSpec(stc.STC_P_COMMENTBLOCK, p['comment'])

        edge_column = p['edge_column']
        if edge_column is not None and edge_column > 0:
            #we add a vertical line to console widget
            self.SetEdgeMode(stc.STC_EDGE_LINE)
            self.SetEdgeColumn(edge_column)
 
 
    #--------------------------------------------------------------------------
    # EditWindow API
    #--------------------------------------------------------------------------

    def OnUpdateUI(self, event):
        """ Override the OnUpdateUI of the EditWindow class, to prevent 
            syntax highlighting both for faster redraw, and for more
            consistent look and feel.
        """

       
    #--------------------------------------------------------------------------
    # Private API
    #--------------------------------------------------------------------------
   
    def _on_key_down(self, event, skip=True):
        """ Key press callback used for correcting behavior for 
            console-like interfaces: the cursor is constraint to be after
            the last prompt.

            Return True if event as been catched.
        """
        catched = True
        # XXX: Would the right way to do this be to have a
        #  dictionary at the instance level associating keys with
        #  callbacks? How would we deal with inheritance? And Do the
        #  different callbacks share local variables?

        # Intercept some specific keys.
        key_code = event.GetKeyCode()
        if key_code == ord('L') and event.ControlDown() :
            self.scroll_to_bottom()
        elif key_code == ord('K') and event.ControlDown() :
            self.input_buffer = ''
        elif key_code == ord('A') and event.ControlDown() :
            self.GotoPos(self.GetLength())
            self.SetSelectionStart(self.current_prompt_pos)
            self.SetSelectionEnd(self.GetCurrentPos())
            catched = True
        elif key_code == ord('E') and event.ControlDown() :
            self.GotoPos(self.GetLength())
            catched = True
        elif key_code == wx.WXK_PAGEUP:
            self.ScrollPages(-1)
        elif key_code == wx.WXK_PAGEDOWN:
            self.ScrollPages(1)
        elif key_code == wx.WXK_HOME:
            self.GotoPos(self.GetLength())
        elif key_code == wx.WXK_END:
            self.GotoPos(self.GetLength())
        elif key_code == wx.WXK_UP and event.ShiftDown():
            self.ScrollLines(-1)
        elif key_code == wx.WXK_DOWN and event.ShiftDown():
            self.ScrollLines(1)
        else:
            catched = False

        if self.AutoCompActive():
            event.Skip()
        else:
            if key_code in (13, wx.WXK_NUMPAD_ENTER):
                # XXX: not catching modifiers, to be wx2.6-compatible
                catched = True
                if not self.enter_catched:
                    self.CallTipCancel()
                    if event.ShiftDown():
                        # Try to force execution
                        self.GotoPos(self.GetLength())
                        self.write('\n' + self.continuation_prompt(), 
                                        refresh=False)
                        self._on_enter()
                    else:
                        self._on_enter()
                    self.enter_catched = True

            elif key_code == wx.WXK_HOME:
                if not event.ShiftDown():
                    self.GotoPos(self.current_prompt_pos)
                    catched = True
                else:
                    # FIXME: This behavior is not ideal: if the selection
                    # is already started, it will jump.
                    self.SetSelectionStart(self.current_prompt_pos) 
                    self.SetSelectionEnd(self.GetCurrentPos())
                    catched = True

            elif key_code == wx.WXK_UP:
                if self.GetCurrentLine() > self.current_prompt_line:
                    if self.GetCurrentLine() == self.current_prompt_line + 1 \
                            and self.GetColumn(self.GetCurrentPos()) < \
                                self.GetColumn(self.current_prompt_pos):
                        self.GotoPos(self.current_prompt_pos)
                    else:
                        event.Skip()
                catched = True

            elif key_code in (wx.WXK_LEFT, wx.WXK_BACK):
                if not self._keep_cursor_in_buffer(self.GetCurrentPos() - 1):
                    event.Skip()
                catched = True

            elif key_code == wx.WXK_RIGHT:
                if not self._keep_cursor_in_buffer(self.GetCurrentPos() + 1):
                    event.Skip()
                catched = True


            elif key_code == wx.WXK_DELETE:
                if not self._keep_cursor_in_buffer(self.GetCurrentPos() - 1):
                    event.Skip()
                catched = True

            if skip and not catched:
                # Put the cursor back in the edit region
                if not self._keep_cursor_in_buffer():
                    if not (self.GetCurrentPos() == self.GetLength()
                                and key_code == wx.WXK_DELETE):
                        event.Skip()
                    catched = True

        return catched


    def _on_key_up(self, event, skip=True):
        """ If cursor is outside the editing region, put it back.
        """
        if skip:
            event.Skip()
        self._keep_cursor_in_buffer()


    # XXX:  I need to avoid the problem of having an empty glass;
    def _keep_cursor_in_buffer(self, pos=None):
        """ Checks if the cursor is where it is allowed to be. If not,
            put it back.

            Returns
            -------
            cursor_moved: Boolean
                whether or not the cursor was moved by this routine.

            Notes
            ------
                WARNING: This does proper checks only for horizontal
                movements.
        """
        if pos is None:
            current_pos = self.GetCurrentPos()
        else:
            current_pos = pos
        if  current_pos < self.current_prompt_pos:
            self.GotoPos(self.current_prompt_pos)
            return True
        line_num = self.LineFromPosition(current_pos)
        if not current_pos > self.GetLength():
            line_pos = self.GetColumn(current_pos)
        else:
            line_pos = self.GetColumn(self.GetLength())
        line = self.GetLine(line_num)
        # Jump the continuation prompt
        continuation_prompt = self.continuation_prompt()
        if ( line.startswith(continuation_prompt)
                     and line_pos < len(continuation_prompt)):
            if line_pos < 2:
                # We are at the beginning of the line, trying to move
                # forward: jump forward.
                self.GotoPos(current_pos + 1 +
                                    len(continuation_prompt) - line_pos)
            else:
                # Jump back up
                self.GotoPos(self.GetLineEndPosition(line_num-1))
            return True
        elif ( current_pos > self.GetLineEndPosition(line_num) 
                        and not current_pos == self.GetLength()): 
            # Jump to next line
            self.GotoPos(current_pos + 1 +
                                    len(continuation_prompt))
            return True

        # We re-allow enter event processing
        self.enter_catched = False 
        return False


if __name__ == '__main__':
    # Some simple code to test the console widget.
    class MainWindow(wx.Frame):
        def __init__(self, parent, id, title):
            wx.Frame.__init__(self, parent, id, title, size=(300, 250))
            self._sizer = wx.BoxSizer(wx.VERTICAL)
            self.console_widget = ConsoleWidget(self)
            self._sizer.Add(self.console_widget, 1, wx.EXPAND)
            self.SetSizer(self._sizer)
            self.SetAutoLayout(1)
            self.Show(True)

    app = wx.PySimpleApp()
    w = MainWindow(None, wx.ID_ANY, 'ConsoleWidget')
    w.SetSize((780, 460))
    w.Show()

    app.MainLoop()