#
#   pyconsole.py
#
#   Copyright (C) 2004-2006 by Yevgen Muntyan <muntyan@math.tamu.edu>
#   Portions of code by Geoffrey French.
#
#   This program is free software: you can redistribute it and/or modify
#   it under the terms of the GNU Lesser General Public version 2.1 as
#   published by the Free Software Foundation.
#
#   See COPYING.lib file that comes with this distribution for full text
#   of the license.
#

# This module 'runs' python interpreter in a TextView widget.
# The main class is Console, usage is:
# Console(locals=None, banner=None, completer=None, use_rlcompleter=True, start_script='') -
# it creates the widget and 'starts' interactive session; see the end
# of this file. If start_script is not empty, it pastes it as it was
# entered from keyboard.
#
# Console has "command" signal which is emitted when code is about to
# be executed. You may connect to it using console.connect or
# console.connect_after to get your callback ran before or after the
# code is executed.
#
# To modify output appearance, set attributes of console.stdout_tag and
# console.stderr_tag.
#
# Console may subclass a type other than gtk.TextView, to allow syntax
# highlighting and stuff,
# e.g.:
#   console_type = pyconsole.ConsoleType(moo.edit.TextView)
#   console = console_type(use_rlcompleter=False, start_script="import moo\nimport gtk\n")
#
# This widget is not a replacement for real terminal with python running
# inside: GtkTextView is not a terminal.
# The use case is: you have a python program, you create this widget,
# and inspect your program interiors.

import gtk
import gtk.gdk as gdk
import gobject
import pango
import gtk.keysyms as _keys
import code
import sys
import keyword
import re

# commonprefix() from posixpath
def _commonprefix(m):
    "Given a list of pathnames, returns the longest common leading component"
    if not m: return ''
    prefix = m[0]
    for item in m:
        for i in range(len(prefix)):
            if prefix[:i+1] != item[:i+1]:
                prefix = prefix[:i]
                if i == 0:
                    return ''
                break
    return prefix

class _ReadLine(object):

    class Output(object):
        def __init__(self, console, tag_name):
            object.__init__(self)
            self.buffer = console.get_buffer()
            self.tag_name = tag_name
        def write(self, text):
            pos = self.buffer.get_iter_at_mark(self.buffer.get_insert())
            self.buffer.insert_with_tags_by_name(pos, text, self.tag_name)

    class History(object):
        def __init__(self):
            object.__init__(self)
            self.items = ['']
            self.ptr = 0
            self.edited = {}

        def commit(self, text):
            if text and self.items[-1] != text:
                self.items.append(text)
            self.ptr = 0
            self.edited = {}

        def get(self, dir, text):
            if len(self.items) == 1:
                return None

            if text != self.items[self.ptr]:
                self.edited[self.ptr] = text
            elif self.edited.has_key(self.ptr):
                del self.edited[self.ptr]

            self.ptr = self.ptr + dir
            if self.ptr >= len(self.items):
                self.ptr = 0
            elif self.ptr < 0:
                self.ptr = len(self.items) - 1

            try:
                return self.edited[self.ptr]
            except KeyError:
                return self.items[self.ptr]

    def __init__(self, quit_func=None):
        object.__init__(self)

        self.quit_func = quit_func

        self.set_wrap_mode(gtk.WRAP_CHAR)
        self.modify_font(pango.FontDescription("Monospace"))

        self.buffer = self.get_buffer()
        self.buffer.connect("insert-text", self.on_buf_insert)
        self.buffer.connect("delete-range", self.on_buf_delete)
        self.buffer.connect("mark-set", self.on_buf_mark_set)
        self.do_insert = False
        self.do_delete = False

        self.stdout_tag = self.buffer.create_tag("stdout", foreground="#006000")
        self.stderr_tag = self.buffer.create_tag("stderr", foreground="#B00000")
        self._stdout = _ReadLine.Output(self, "stdout")
        self._stderr = _ReadLine.Output(self, "stderr")

        self.cursor = self.buffer.create_mark("cursor",
                                              self.buffer.get_start_iter(),
                                              False)
        insert = self.buffer.get_insert()
        self.cursor.set_visible(True)
        insert.set_visible(False)

        self.ps = ''
        self.in_raw_input = False
        self.in_modal_raw_input = False
        self.run_on_raw_input = None
        self.tab_pressed = 0
        self.history = _ReadLine.History()
        self.nonword_re = re.compile("[^\w\._]")

    def freeze_undo(self):
        try: self.begin_not_undoable_action()
        except: pass

    def thaw_undo(self):
        try: self.end_not_undoable_action()
        except: pass

    def raw_input(self, ps=None):
        '''Show prompt 'ps' and enter input mode until the current input
        is committed.'''

        if ps:
            self.ps = ps
        else:
            self.ps = ''

        iter = self.buffer.get_iter_at_mark(self.buffer.get_insert())

        if ps:
            self.freeze_undo()
            self.buffer.insert(iter, self.ps)
            self.thaw_undo()

        self.__move_cursor_to(iter)
        self.scroll_to_mark(self.cursor, 0.2)

        self.in_raw_input = True

        if self.run_on_raw_input:
            run_now = self.run_on_raw_input
            self.run_on_raw_input = None
            self.buffer.insert_at_cursor(run_now + '\n')

    def modal_raw_input(self, text):
        '''Starts raw input in modal mode. The event loop is spinned until
        the input is committed. Returns the text entered after the prompt.'''
        orig_ps = self.ps

        self.raw_input(text)
        self.in_modal_raw_input = True

        while self.in_modal_raw_input:
            gtk.main_iteration()

        self.ps = orig_ps
        self.in_modal_raw_input = False
        self.in_raw_input = False

        return self.modal_raw_input_result

    def modal_input(self, text):
        return eval(self.modal_raw_input(text))

    # Each time the insert mark is modified, move the cursor to it.
    def on_buf_mark_set(self, buffer, iter, mark):
        if mark is not buffer.get_insert():
            return
        start = self.__get_start()
        end = self.__get_end()
        if iter.compare(self.__get_start()) >= 0 and \
           iter.compare(self.__get_end()) <= 0:
                buffer.move_mark_by_name("cursor", iter)
                self.scroll_to_mark(self.cursor, 0.2)

    def __insert(self, iter, text):
        self.do_insert = True
        self.buffer.insert(iter, text)
        self.do_insert = False

    # Make sure that text insertions while in text input mode are properly
    # committed to the history.
    def on_buf_insert(self, buf, iter, text, len):
        # Bail out if not in input mode.
        if not self.in_raw_input or self.do_insert or not len:
            return

        buf.stop_emission("insert-text")
        lines = text.splitlines()
        need_eol = False
        for l in lines:
            if need_eol:
                self._commit()
                iter = self.__get_cursor()
            else:
                cursor = self.__get_cursor()
                if iter.compare(self.__get_start()) < 0:
                    iter = cursor
                elif iter.compare(self.__get_end()) > 0:
                    iter = cursor
                else:
                    self.__move_cursor_to(iter)
            need_eol = True
            self.__insert(iter, l)
        self.__move_cursor(0)

    def __delete(self, start, end):
        self.do_delete = True
        self.buffer.delete(start, end)
        self.do_delete = False

    def on_buf_delete(self, buf, start, end):
        if not self.in_raw_input or self.do_delete:
            return

        buf.stop_emission("delete-range")

        start.order(end)
        line_start = self.__get_start()
        line_end = self.__get_end()

        if start.compare(line_end) > 0:
            return
        if end.compare(line_start) < 0:
            return

        self.__move_cursor(0)

        if start.compare(line_start) < 0:
            start = line_start
        if end.compare(line_end) > 0:
            end = line_end
        self.__delete(start, end)

    # We overload the key press event handler to handle "special keys"
    # when in input mode to make history browsing, completions, etc. work.
    def do_key_press_event(self, event, parent_type):
        if not self.in_raw_input:
            return parent_type.do_key_press_event(self, event)

        tab_pressed = self.tab_pressed
        self.tab_pressed = 0
        handled = True

        state = event.state & (gdk.SHIFT_MASK |
                                gdk.CONTROL_MASK |
                                gdk.MOD1_MASK)
        keyval = event.keyval

        if not state:
            if keyval == _keys.Escape:
                pass
            elif keyval == _keys.Return:
                self._commit()
            elif keyval == _keys.Up:
                self.__history(-1)
            elif keyval == _keys.Down:
                self.__history(1)
            elif keyval == _keys.Left:
                self.__move_cursor(-1)
            elif keyval == _keys.Right:
                self.__move_cursor(1)
            elif keyval == _keys.Home:
                self.__move_cursor(-10000)
            elif keyval == _keys.End:
                self.__move_cursor(10000)
            elif keyval == _keys.Tab:
                cursor = self.__get_cursor()
                if cursor.starts_line():
                    handled = False
                else:
                    cursor.backward_char()
                    if cursor.get_char().isspace():
                        handled = False
                    else:
                        self.tab_pressed = tab_pressed + 1
                        self.__complete()
            else:
                handled = False
        elif state == gdk.CONTROL_MASK:
            if keyval == _keys.u:
                start = self.__get_start()
                end = self.__get_cursor()
                self.__delete(start, end)
            elif keyval == _keys.d:
                if self.quit_func:
                    self.quit_func()
            else:
                handled = False
        else:
            handled = False

        # Handle ordinary keys
        if not handled:
            return parent_type.do_key_press_event(self, event)
        else:
            return True

    def __history(self, dir):
        text = self._get_line()
        new_text = self.history.get(dir, text)
        if not new_text is None:
            self.__replace_line(new_text)
        self.__move_cursor(0)
        self.scroll_to_mark(self.cursor, 0.2)

    def __get_cursor(self):
        '''Returns an iterator at the current cursor position.'''
        return self.buffer.get_iter_at_mark(self.cursor)

    def __get_start(self):
        '''Returns an iterator at the start of the input on the current
        cursor line.'''

        iter = self.__get_cursor()
        iter.set_line_offset(len(self.ps))
        return iter

    def __get_end(self):
        '''Returns an iterator at the end of the cursor line.'''
        iter = self.__get_cursor()
        if not iter.ends_line():
            iter.forward_to_line_end()
        return iter

    def __get_text(self, start, end):
        '''Get text between 'start' and 'end' markers.'''
        return self.buffer.get_text(start, end, False)

    def __move_cursor_to(self, iter):
        self.buffer.place_cursor(iter)
        self.buffer.move_mark_by_name("cursor", iter)

    def __move_cursor(self, howmany):
        iter = self.__get_cursor()
        end = self.__get_cursor()
        if not end.ends_line():
            end.forward_to_line_end()
        line_len = end.get_line_offset()
        move_to = iter.get_line_offset() + howmany
        move_to = min(max(move_to, len(self.ps)), line_len)
        iter.set_line_offset(move_to)
        self.__move_cursor_to(iter)

    def __delete_at_cursor(self, howmany):
        iter = self.__get_cursor()
        end = self.__get_cursor()
        if not end.ends_line():
            end.forward_to_line_end()
        line_len = end.get_line_offset()
        erase_to = iter.get_line_offset() + howmany
        if erase_to > line_len:
            erase_to = line_len
        elif erase_to < len(self.ps):
            erase_to = len(self.ps)
        end.set_line_offset(erase_to)
        self.__delete(iter, end)

    def __get_width(self):
        '''Estimate the number of characters that will fit in the area
        currently allocated to this widget.'''

        if not (self.flags() & gtk.REALIZED):
            return 80

        context = self.get_pango_context()
        metrics = context.get_metrics(context.get_font_description(),
                                      context.get_language())
        pix_width = metrics.get_approximate_char_width()
        return self.allocation.width * pango.SCALE / pix_width

    def __print_completions(self, completions):
        line_start = self.__get_text(self.__get_start(), self.__get_cursor())
        line_end = self.__get_text(self.__get_cursor(), self.__get_end())
        iter = self.buffer.get_end_iter()
        self.__move_cursor_to(iter)
        self.__insert(iter, "\n")

        width = max(self.__get_width(), 4)
        max_width = max(len(s) for s in completions)
        n_columns = max(int(width / (max_width + 1)), 1)
        col_width = int(width / n_columns)
        total = len(completions)
        col_length = total / n_columns
        if total % n_columns:
            col_length = col_length + 1
        col_length = max(col_length, 1)

        if col_length == 1:
            n_columns = total
            col_width = width / total

        for i in range(col_length):
            for j in range(n_columns):
                ind = i + j*col_length
                if ind < total:
                    if j == n_columns - 1:
                        n_spaces = 0
                    else:
                        n_spaces = col_width - len(completions[ind])
                    self.__insert(iter, completions[ind] + " " * n_spaces)
            self.__insert(iter, "\n")

        self.__insert(iter, "%s%s%s" % (self.ps, line_start, line_end))
        iter.set_line_offset(len(self.ps) + len(line_start))
        self.__move_cursor_to(iter)
        self.scroll_to_mark(self.cursor, 0.2)

    def __complete(self):
        text = self.__get_text(self.__get_start(), self.__get_cursor())
        start = ''
        word = text
        nonwords = self.nonword_re.findall(text)
        if nonwords:
            last = text.rfind(nonwords[-1]) + len(nonwords[-1])
            start = text[:last]
            word = text[last:]

        completions = self.complete(word)

        if completions:
            prefix = _commonprefix(completions)
            if prefix != word:
                start_iter = self.__get_start()
                start_iter.forward_chars(len(start))
                end_iter = start_iter.copy()
                end_iter.forward_chars(len(word))
                self.__delete(start_iter, end_iter)
                self.__insert(end_iter, prefix)
            elif self.tab_pressed > 1:
                self.freeze_undo()
                self.__print_completions(completions)
                self.thaw_undo()
                self.tab_pressed = 0

    def complete(self, text):
        return None

    def _get_line(self):
        '''Return the current input behind the prompt.'''
        start = self.__get_start()
        end = self.__get_end()
        return self.buffer.get_text(start, end, False)

    def __replace_line(self, new_text):
        '''Replace the current input with 'new_text' '''
        start = self.__get_start()
        end = self.__get_end()
        self.__delete(start, end)
        self.__insert(end, new_text)

    def _commit(self):
        '''Commit the input entered on the current line.'''

        # Find iterator and end of cursor line.
        end = self.__get_cursor()
        if not end.ends_line():
            end.forward_to_line_end()

        # Get text at current line.
        text = self._get_line()

        # Move cursor to the end of the line, insert new line.
        self.__move_cursor_to(end)
        self.freeze_undo()
        self.__insert(end, "\n")

        self.history.commit(text)
        if self.in_modal_raw_input:
            self.in_modal_raw_input = False
            self.modal_raw_input_result = text
        else:
            self.in_raw_input = False
            self.do_raw_input(text)

        self.thaw_undo()

    def do_raw_input(self, text):
        pass


class _Console(_ReadLine, code.InteractiveInterpreter):
    def __init__(self, locals=None, banner=None,
                 completer=None, use_rlcompleter=True,
                 start_script=None, quit_func=None):
        _ReadLine.__init__(self, quit_func)

        code.InteractiveInterpreter.__init__(self, locals)
        self.locals["__console__"] = self

        # The builtin raw_input function reads from stdin, we don't want
        # this. Therefore, replace this function with our own modal raw
        # input function.
        exec "import __builtin__" in self.locals
        self.locals['__builtin__'].__dict__['raw_input'] = lambda text='': self.modal_raw_input(text)
        self.locals['__builtin__'].__dict__['input'] = lambda text='': self.modal_input(text)

        self.start_script = start_script
        self.completer = completer
        self.banner = banner

        if not self.completer and use_rlcompleter:
            try:
                import rlcompleter
                self.completer = rlcompleter.Completer()
            except ImportError:
                pass

        self.ps1 = ">>> "
        self.ps2 = "... "
        self.__start()
        self.run_on_raw_input = start_script
        self.raw_input(self.ps1)

    def __start(self):
        self.cmd_buffer = ""

        self.freeze_undo()
        self.thaw_undo()

        self.do_delete = True
        self.buffer.set_text("")
        self.do_delete = False

        if self.banner:
            iter = self.buffer.get_start_iter()
            self.buffer.insert_with_tags_by_name(iter, self.banner, "stdout")
            if not iter.starts_line():
                self.buffer.insert(iter, "\n")

    def clear(self, start_script=None):
        if start_script is None:
            start_script = self.start_script
        else:
            self.start_script = start_script

        self.__start()
        self.run_on_raw_input = start_script

    def do_raw_input(self, text):
        if self.cmd_buffer:
            cmd = self.cmd_buffer + "\n" + text
        else:
            cmd = text

        saved_stdout, saved_stderr = sys.stdout, sys.stderr
        sys.stdout, sys.stderr = self._stdout, self._stderr

        if self.runsource(cmd):
            self.cmd_buffer = cmd
            ps = self.ps2
        else:
            self.cmd_buffer = ''
            ps = self.ps1

        sys.stdout, sys.stderr = saved_stdout, saved_stderr
        self.raw_input(ps)

    def do_command(self, code):
        try:
            eval(code, self.locals)
        except SystemExit:
            if self.quit_func:
                self.quit_func()
            else:
                raise
        except:
            self.showtraceback()

    def runcode(self, code):
        if gtk.pygtk_version[1] < 8:
            self.do_command(code)
        else:
            self.emit("command", code)

    def complete_attr(self, start, end):
        try:
            obj = eval(start, self.locals)
            strings = dir(obj)

            if end:
                completions = {}
                for s in strings:
                    if s.startswith(end):
                        completions[s] = None
                completions = completions.keys()
            else:
                completions = strings

            completions.sort()
            return [start + "." + s for s in completions]
        except:
            return None

    def complete(self, text):
        if self.completer:
            completions = []
            i = 0
            try:
                while 1:
                    s = self.completer.complete(text, i)
                    if s:
                        completions.append(s)
                        i += 1
                    else:
                        completions.sort()
                        return completions
            except NameError:
                return None

        dot = text.rfind(".")
        if dot >= 0:
            return self.complete_attr(text[:dot], text[dot+1:])

        completions = {}
        strings = keyword.kwlist

        if self.locals:
            strings.extend(self.locals.keys())

        try: strings.extend(eval("globals()", self.locals).keys())
        except: pass

        try:
            exec "import __builtin__" in self.locals
            strings.extend(eval("dir(__builtin__)", self.locals))
        except:
            pass

        for s in strings:
            if s.startswith(text):
                completions[s] = None
        completions = completions.keys()
        completions.sort()
        return completions


def ReadLineType(t=gtk.TextView):
    class readline(t, _ReadLine):
        def __init__(self, *args, **kwargs):
            t.__init__(self)
            _ReadLine.__init__(self, *args, **kwargs)
        def do_key_press_event(self, event):
            return _ReadLine.do_key_press_event(self, event, t)
    gobject.type_register(readline)
    return readline

def ConsoleType(t=gtk.TextView):
    class console_type(t, _Console):
        __gsignals__ = {
            'command' : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (object,)),
            'key-press-event' : 'override'
          }

        def __init__(self, *args, **kwargs):
            if gtk.pygtk_version[1] < 8:
                gobject.GObject.__init__(self)
            else:
                t.__init__(self)
            _Console.__init__(self, *args, **kwargs)

        def do_command(self, code):
            return _Console.do_command(self, code)

        def do_key_press_event(self, event):
            return _Console.do_key_press_event(self, event, t)

        def get_default_size(self):
            context = self.get_pango_context()
            metrics = context.get_metrics(context.get_font_description(),
                                          context.get_language())
            width = metrics.get_approximate_char_width()
            height = metrics.get_ascent() + metrics.get_descent()

            # Default to a 80x40 console
            width = pango.PIXELS(int(width * 80 * 1.05))
            height = pango.PIXELS(height * 40)

            return width, height

    if gtk.pygtk_version[1] < 8:
        gobject.type_register(console_type)

    return console_type

ReadLine = ReadLineType()
Console = ConsoleType()

def _make_window():
    window = gtk.Window()
    window.set_title("pyconsole.py")
    swin = gtk.ScrolledWindow()
    swin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_ALWAYS)
    window.add(swin)
    console = Console(banner="Hello there!",
                      use_rlcompleter=False,
                      start_script="from gtk import *\n")
    swin.add(console)

    width, height = console.get_default_size()
    sb_width, sb_height = swin.get_vscrollbar().size_request()

    window.set_default_size(width + sb_width, height)
    window.show_all()

    if not gtk.main_level():
        window.connect("destroy", gtk.main_quit)
        gtk.main()

    return console

if __name__ == '__main__':
    if len(sys.argv) < 2 or sys.argv[1] != '-gimp':
        _make_window()
