hugoruscitti/pilas

View on GitHub
pilasengine/interprete/editor.py

Summary

Maintainability
F
3 days
Test Coverage
# -*- encoding: utf-8 -*-
# pilas engine: un motor para hacer videojuegos
#
# Copyright 2010-2014 - Hugo Ruscitti
# License: LGPLv3 (see http://www.gnu.org/licenses/lgpl.html)
#
# Website - http://www.pilas-engine.com.ar
import codecs
import re
import sys
import os
import inspect
import tempfile
import shutil

from PyQt4.QtCore import Qt, QTimer
from PyQt4.Qt import (QFrame, QWidget, QPainter,
                      QSize, QVariant)
from PyQt4.QtGui import (QTextEdit, QTextCursor, QFileDialog,
                         QIcon, QMessageBox, QShortcut,
                         QInputDialog, QLineEdit, QErrorMessage,
                         QKeySequence, QTextFormat, QColor, QKeyEvent)
from PyQt4.QtCore import Qt
from PyQt4 import QtCore


from editorbase import editor_base
import editor_ui
import pilasengine


CODIGO_INICIAL = u"""# coding: utf-8
import pilasengine

pilas = pilasengine.iniciar()

mono = pilas.actores.Mono()

# Algunas transformaciones:
# (Pulsá el botón derecho del
#  mouse sobre alguna de las
#  sentencias)

mono.x = 0
mono.y = 0
mono.escala = 1.0
mono.rotacion = 0

pilas.ejecutar()"""


class WidgetEditor(QWidget, editor_ui.Ui_Editor):

    class NumberBar(QWidget):

        def __init__(self, *args):
            QWidget.__init__(self, *args)
            self.edit = None
            # This is used to update the width of the control.
            # It is the highest line that is currently visible.
            self.highest_line = 0

        def setTextEdit(self, edit):
            self.edit = edit

        def update(self, *args):
            width = self.fontMetrics().width(str(self.highest_line)) + 4

            if self.width() != width:
                self.setFixedWidth(width + 15)

            QWidget.update(self, *args)

        def paintEvent(self, event):
            contents_y = self.edit.verticalScrollBar().value()
            page_bottom = contents_y + self.edit.viewport().height()
            font_metrics = self.fontMetrics()

            painter = QPainter(self)

            line_count = 0

            block = self.edit.document().begin()

            while block.isValid():
                line_count += 1

                # The top left position of the block in the document
                position = self.edit.document().documentLayout().blockBoundingRect(block).topLeft()

                # Check if the position of the block is out side of the visible
                # area.
                if position.y() > page_bottom:
                    break

                # Draw the line number right justified at the y position of the
                # line. 3 is a magic padding number. drawText(x, y, text).
                painter.drawText(-5 + self.width() - font_metrics.width(str(line_count)) - 3,
                                round(position.y()) - contents_y + font_metrics.ascent(),
                                str(line_count))

                block = block.next()

            self.highest_line = line_count
            painter.end()

            QWidget.paintEvent(self, event)

    def __init__(self, main=None, interpreter_locals=None, consola_lanas=None, ventana_interprete=None, *args):
        QWidget.__init__(self, *args)
        self.setupUi(self)
        self.setLayout(self.vertical_layout)
        self.ruta_del_archivo_actual = None
        self.consola_lanas = consola_lanas
        self.ventana_interprete = ventana_interprete

        if interpreter_locals is None:
            interpreter_locals = locals()

        self.interpreter_locals = interpreter_locals
        self.lista_actores_como_strings = []

        self.editor = Editor(self, interpreter_locals, consola_lanas, ventana_interprete)
        self.editor.setFrameStyle(QFrame.NoFrame)
        self.editor.setAcceptRichText(False)

        self.number_bar = self.NumberBar()
        self.number_bar.setTextEdit(self.editor)

        # Agregando editor y number_bar a hbox_editor layout
        self.hbox_editor.addWidget(self.number_bar)
        self.hbox_editor.addWidget(self.editor)

        # Boton Abrir
        self.set_icon(self.boton_abrir, 'iconos/abrir.png')
        self.boton_abrir.connect(self.boton_abrir,
                                    QtCore.SIGNAL('clicked()'),
                                    self.editor.abrir_archivo_con_dialogo)

        # Boton Guardar
        self.set_icon(self.boton_guardar, 'iconos/guardar.png')
        self.boton_guardar.connect(self.boton_guardar,
                                    QtCore.SIGNAL('clicked()'),
                                    self.editor.guardar_contenido_directamente)

        # Boton Guardar Como ...
        self.set_icon(self.boton_guardar_como, 'iconos/guardar_como.png')
        self.boton_guardar_como.connect(self.boton_guardar_como,
                                    QtCore.SIGNAL('clicked()'),
                                    self.editor.guardar_contenido_con_dialogo)

        # Boton actualizar
        self.set_icon(self.boton_actualizar, 'iconos/actualizar.png')
        self.boton_actualizar.connect(self.boton_actualizar,
                                    QtCore.SIGNAL('clicked()'),
                                    self.actualizar_el_listado_de_archivos)

        # Boton actualizar
        self.set_icon(self.boton_nuevo, 'iconos/nuevo.png')
        self.boton_nuevo.connect(self.boton_nuevo,
                                    QtCore.SIGNAL('clicked()'),
                                    self.pulsa_boton_crear_archivo_nuevo)

        self.deshabilitar_boton_nuevo()

        # Boton Ejecutar
        self.set_icon(self.boton_ejecutar, 'iconos/ejecutar.png')
        self.boton_ejecutar.connect(self.boton_ejecutar,
                                    QtCore.SIGNAL('clicked()'),
                                    self.cuando_pulsa_el_boton_ejecutar)

        # Boton Pausar
        self.set_icon(self.boton_pausar, 'iconos/pausa.png')
        self.boton_pausar.connect(self.boton_pausar,
                                    QtCore.SIGNAL('clicked()'),
                                    self.cuando_pulsa_el_boton_pausar)

        # Boton Siguiente
        self.set_icon(self.boton_siguiente, 'iconos/siguiente.png')
        self.boton_siguiente.connect(self.boton_siguiente,
                                    QtCore.SIGNAL('clicked()'),
                                    self.cuando_pulsa_el_boton_siguiente)

        self._vincular_atajos_de_teclado()

        self.editor.installEventFilter(self)
        self.editor.viewport().installEventFilter(self)

        self.timer_id = self.startTimer(1000 / 2.0)
        self.lista_actores.currentItemChanged.connect(self.cuando_selecciona_item)

        self.selector_archivos.activated[str].connect(self.cuando_cambia_archivo_seleccionado)
        self.actualizar_el_listado_de_archivos()

    def deshabilitar_boton_nuevo(self):
        self.boton_nuevo.setEnabled(False)
        self.boton_nuevo.setToolTip("Crear un nuevo archivo (deshabilitado porque no has guardado aún)")

    def habilitar_boton_nuevo(self):
        self.boton_nuevo.setEnabled(True)
        self.boton_nuevo.setToolTip("Crear un nuevo archivo")

    def definir_fuente(self, fuente):
        self.lista_actores.setFont(fuente)
        self.number_bar.setFont(fuente)

    def pulsa_boton_crear_archivo_nuevo(self):
        text, ok = QInputDialog.getText(self, 'Crear un archivo nuevo', 'Nombre del archivo:', QLineEdit.Normal, "nuevo")

        if ok and text:
            self.editor.crear_y_seleccionar_archivo(text)

    def cuando_selecciona_item(self, actual, anterior):
        indice = self.lista_actores.indexFromItem(actual).row()
        if indice > -1:
            self.editor.cuando_selecciona_actor_por_indice(indice)

    def timerEvent(self, event):
        lista_actores = self.interpreter_locals['pilas'].escena._actores.obtener_actores()
        nueva_lista_de_actores = [str(x) for x in lista_actores]

        if self.lista_actores_como_strings != nueva_lista_de_actores:
            self.lista_actores.clear()
            self.lista_actores_como_strings = nueva_lista_de_actores
            self.lista_actores.addItems(self.lista_actores_como_strings)

    def eventFilter(self, obj, event):
        if obj in (self.editor, self.editor.viewport()):
            self.number_bar.update()
            return False
        return QWidget.eventFilter(obj, event)

    def set_icon(self, boton, ruta, text=''):
        icon = QIcon()
        archivo = pilasengine.utils.obtener_ruta_al_recurso(ruta)
        icon.addFile(archivo, QSize(), QIcon.Normal, QIcon.Off)
        boton.setIcon(icon)
        boton.setText(text)

    def _vincular_atajos_de_teclado(self):
        QShortcut(QKeySequence("F5"), self, self.cuando_pulsa_el_boton_ejecutar)
        QShortcut(QKeySequence("Ctrl+r"), self, self.cuando_pulsa_el_boton_ejecutar)
        QShortcut(QKeySequence("Ctrl+s"), self, self.guardar_y_ejecutar)

        # Solo en MacOS informa que la tecla Command sustituye a CTRL.
        if sys.platform == 'darwin':
            self.boton_ejecutar.setToolTip(u"Ejecutar el código actual (F5, ⌘R, ⌘S)")

    def closeEvent(self, event):
        if not self.editor.salir():
            event.ignore()
            return

        event.accept()

    def guardar_y_ejecutar(self):
        self.cuando_pulsa_el_boton_ejecutar()
        self.editor.guardar_contenido_directamente()

    def cuando_pulsa_el_boton_ejecutar(self):
        self.editor.ejecutar()
        self.boton_pausar.setChecked(False)

    def cuando_pulsa_el_boton_pausar(self):
        if self.boton_pausar.isChecked():
            self.editor.interpreterLocals['pilas'].widget.pausar()
        else:
            self.editor.interpreterLocals['pilas'].widget.continuar()

    def cuando_pulsa_el_boton_siguiente(self):
        if not self.boton_pausar.isChecked():
            self.boton_pausar.click()

        self.editor.interpreterLocals['pilas'].widget.avanzar_un_solo_cuadro()

    def actualizar_el_listado_de_archivos(self):
        self.selector_archivos.clear()

        if self.editor.es_archivo_iniciar_sin_guardar():
            self.selector_archivos.addItem(u"archivo sin título ...")
        else:
            archivos = self.editor.obtener_archivos_del_proyecto()

            for archivo in archivos:
                self.selector_archivos.addItem(archivo)

            index = self.selector_archivos.findText(os.path.basename(self.editor.nombre_de_archivo_sugerido))

            if index != -1:
                self.selector_archivos.setCurrentIndex(index)

    def cuando_cambia_archivo_seleccionado(self, text):
        text = unicode(text)

        if not self.editor.es_archivo_iniciar_sin_guardar():
            self.editor.guardar_contenido_directamente()
            self.editor.abrir_archivo_del_proyecto(text)

    def limpiar_interprete(self):
        self.consola_lanas.limpiar()

class Editor(editor_base.EditorBase):
    """Representa el editor de texto que aparece en el panel derecho.

    El editor soporta autocompletado de código y resaltado de sintáxis.
    """

    # Señal es emitida cuando el Editor ejecuta codigo
    signal_ejecutando = QtCore.pyqtSignal()

    def __init__(self, main, interpreterLocals, consola_lanas, ventana_interprete):
        super(Editor, self).__init__()
        self._archivo_temporal = None
        self.cantidad_ejecuciones = 0
        self.consola_lanas = consola_lanas
        self.ventana_interprete = ventana_interprete
        self.ruta_del_archivo_actual = None
        self.interpreterLocals = interpreterLocals
        self.setLineWrapMode(QTextEdit.NoWrap)
        self._cambios_sin_guardar = False
        self.main = main
        self.nombre_de_archivo_sugerido = ""
        self.watcher = pilasengine.watcher.Watcher(None, self.cuando_cambia_archivo_de_forma_externa)

    def crear_archivo_inicial(self):
        dirpath = tempfile.mkdtemp()
        archivo_temporal = os.path.join(dirpath, "mi_juego.py")
        archivo = codecs.open(archivo_temporal, "w", 'utf-8')
        archivo.write(CODIGO_INICIAL)
        archivo.close()
        self.abrir_archivo_del_proyecto(archivo_temporal)
        self._archivo_temporal = str(archivo_temporal)

    def eliminar_archivo_temporal(self):
        if self._archivo_temporal:
            os.remove(self._archivo_temporal)

    def es_archivo_iniciar_sin_guardar(self):
        return self.nombre_de_archivo_sugerido == ""

    def crear_y_seleccionar_archivo(self, nombre):
        # Quita la extension si llega a tenerla.
        nombre = nombre.replace('.py', '')
        nombre += ".py"
        nombre_de_archivo = nombre

        if self.ruta_del_archivo_actual:
            base_path = os.path.abspath(os.path.dirname(self.ruta_del_archivo_actual))
            ruta = os.path.join(base_path, unicode(nombre_de_archivo))
        else:
            ruta = unicode(nombre_de_archivo)

        if os.path.exists(ruta):
            self._tmp_dialog = QErrorMessage(self)
            self._tmp_dialog.showMessage("Ya existe un archivo con ese nombre")
        else:
            self._crear_archivo_nuevo_en(ruta)

            self.cargar_contenido_desde_archivo(ruta)
            self.ruta_del_archivo_actual = ruta
            self.watcher.cambiar_archivo_a_observar(ruta)
            self.ejecutar()
            self.main.actualizar_el_listado_de_archivos()

    def _crear_archivo_nuevo_en(self, ruta):
        fo = open(ruta, "wb")
        fo.write("# contenido")
        fo.close()

    def obtener_archivos_del_proyecto(self):
        base_path = os.path.dirname(self.nombre_de_archivo_sugerido)
        listado = os.listdir(base_path)

        return [x for x in listado if x.endswith('.py')]

    def keyPressEvent(self, event):
        "Atiene el evento de pulsación de tecla."
        self._cambios_sin_guardar = True

        # Permite usar tab como seleccionador de la palabra actual
        # en el popup de autocompletado.
        if event.key() in [Qt.Key_Tab]:
            if self.completer and self.completer.popup().isVisible():
                event.ignore()
                nuevo_evento = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
                try:
                    if self.autocomplete(nuevo_evento):
                        return None
                except UnicodeEncodeError:
                    pass
                return None


        if editor_base.EditorBase.keyPressEvent(self, event):
            return None

        # Elimina los pares de caracteres especiales si los encuentra
        if event.key() == Qt.Key_Backspace:
            self._eliminar_pares_de_caracteres()
            self._borrar_un_grupo_de_espacios(event)

        if self.autocomplete(event):
            return None


        if event.key() == Qt.Key_Return:
            cursor = self.textCursor()
            block = self.document().findBlockByNumber(cursor.blockNumber())
            whitespace = re.match(r"(\s*)", unicode(block.text())).group(1)

            linea_anterior = str(block.text()[:])
            cantidad_espacios = linea_anterior.count(' ') / 4

            if linea_anterior[-1:] == ':':
                whitespace = '    ' * (cantidad_espacios + 1)
            else:
                whitespace = '    ' * (cantidad_espacios)

            QTextEdit.keyPressEvent(self, event)
            return self.insertPlainText(whitespace)



        return QTextEdit.keyPressEvent(self, event)

    def _borrar_un_grupo_de_espacios(self, event):
        cursor = self.textCursor()
        block = self.document().findBlockByNumber(cursor.blockNumber())
        whitespace = re.match(r"(.*)", unicode(block.text())).group(1)

        if whitespace.endswith('    '):
            QTextEdit.keyPressEvent(self, event)
            QTextEdit.keyPressEvent(self, event)
            QTextEdit.keyPressEvent(self, event)

    def tiene_cambios_sin_guardar(self):
        return self._cambios_sin_guardar

    def _get_current_line(self):
        "Obtiene la linea en donde se encuentra el cursor."
        tc = self.textCursor()
        tc.select(QTextCursor.LineUnderCursor)
        return tc.selectedText()

    def _get_position_in_block(self):
        tc = self.textCursor()
        position = tc.positionInBlock() - 1
        return position

    def cargar_contenido_desde_archivo(self, ruta):
        "Carga todo el contenido del archivo indicado por ruta."
        with codecs.open(unicode(ruta), 'r', 'utf-8') as archivo:
            contenido = archivo.read()
        self.setText(contenido)

        self.nombre_de_archivo_sugerido = ruta
        self._cambios_sin_guardar = False

    def abrir_dialogo_cargar_archivo(self):
        return QFileDialog.getOpenFileName(self, "Abrir Archivo",
                                   self.nombre_de_archivo_sugerido,
                                   "Archivos python (*.py)",
                                   options=QFileDialog.DontUseNativeDialog)

    def abrir_archivo_del_proyecto(self, nombre_de_archivo):
        if self.tiene_cambios_sin_guardar():
            if self.mensaje_guardar_cambios_abrir():
                if not self.guardar_contenido_con_dialogo():
                    return
            else:
                return

        if self.ruta_del_archivo_actual:
            base_path = os.path.dirname(self.ruta_del_archivo_actual)
            ruta = os.path.join(base_path, unicode(nombre_de_archivo))
        else:
            ruta = unicode(nombre_de_archivo)

        self.cargar_contenido_desde_archivo(ruta)
        self.ruta_del_archivo_actual = ruta
        self.watcher.cambiar_archivo_a_observar(ruta)
        self.ejecutar()
        self.main.actualizar_el_listado_de_archivos()

    def abrir_archivo_con_dialogo(self):
        if self.tiene_cambios_sin_guardar():
            if self.mensaje_guardar_cambios_abrir():
                if not self.guardar_contenido_con_dialogo():
                    return
            else:
                return

        ruta = self.abrir_dialogo_cargar_archivo()

        if ruta:
            ruta = unicode(ruta)
            self.cargar_contenido_desde_archivo(ruta)
            self.ruta_del_archivo_actual = ruta
            self.watcher.cambiar_archivo_a_observar(ruta)
            self.ejecutar()
            self.main.actualizar_el_listado_de_archivos()

    def cuando_cambia_archivo_de_forma_externa(self):
        self.recargar_archivo_actual()

    def recargar_archivo_actual(self):
        if self.ruta_del_archivo_actual and os.path.exists(self.ruta_del_archivo_actual):
            self.cargar_contenido_desde_archivo(self.ruta_del_archivo_actual)
            self.ejecutar()

    def mensaje_guardar_cambios_abrir(self):
        """Realizar una consulta usando un cuadro de dialogo simple
        se utiliza cuando hay cambios sin guardar y se desea abrir un archivo.
        Retorna True si el usuario presiona el boton *Guardar*,
        Retorna False si el usuario presiona *No*."""
        titulo = u"¿Deseas guardar el contenido antes de abrir un archivo?"
        mensaje = u"El contenido se perdera sino los guardas"

        mensaje = QMessageBox.question(self, titulo, mensaje, "Guardar", "No")

        return (not mensaje)

    def mensaje_guardar_cambios_salir(self):
        """Realizar una consulta usando un cuadro de dialogo simple
        se utiliza cuando hay cambios sin guardar y se desa salir del Editor.
        Retorna 0 si el usuario presiona el boton *Salir sin guardar*,
        Retorna 1 si el usuario presiona *Guardar*
        Retorna 2 si presiona *Cancelar*."""

        titulo = u"¿Deseas guardar el contenido antes de salir?"
        mensaje = u"El contenido se perdera sino los guardas"

        return QMessageBox.question(self, titulo, mensaje,
                                    "Salir sin guardar", "Guardar", "Cancelar")

    def marcar_error_en_la_linea(self, numero, descripcion):
        hi_selection = QTextEdit.ExtraSelection()

        hi_selection.format.setBackground(QColor(255, 220, 220))
        hi_selection.format.setProperty(QTextFormat.FullWidthSelection, QVariant(True))
        hi_selection.cursor = self.textCursor()
        posicion_linea = self.document().findBlockByLineNumber(numero).position()
        hi_selection.cursor.setPosition(posicion_linea)
        hi_selection.cursor.clearSelection()

        self.setExtraSelections([hi_selection])

    def guardar_contenido_con_dialogo(self):
        ruta = self.abrir_dialogo_guardar_archivo()

        if not ruta:
            return False

        ruta = unicode(ruta)

        if not ruta.endswith('.py'):
            ruta += '.py'

        if ruta:
            self.guardar_contenido_en_el_archivo(ruta)
            self._cambios_sin_guardar = False
            self.ruta_del_archivo_actual = ruta
            self.nombre_de_archivo_sugerido = ruta
            self.watcher.cambiar_archivo_a_observar(ruta)
            self.cargar_contenido_desde_archivo(ruta)
            self.ejecutar()
            self.main.actualizar_el_listado_de_archivos()
            self.main.habilitar_boton_nuevo()
            return True

    def guardar_contenido_directamente(self):
        if self.nombre_de_archivo_sugerido:
            self.guardar_contenido_en_el_archivo(self.nombre_de_archivo_sugerido)
            self._cambios_sin_guardar = False
            self.main.actualizar_el_listado_de_archivos()
            self.prevenir_live_reload()
        else:
            self.guardar_contenido_con_dialogo()

    def prevenir_live_reload(self):
        self.watcher.prevenir_reinicio()

    def salir(self):
        """Retorna True si puede salir y False si no"""
        if self.tiene_cambios_sin_guardar():
            mensaje = self.mensaje_guardar_cambios_salir()
            if mensaje == 1:
                self.guardar_contenido_con_dialogo()
            elif mensaje == 2:
                return False

        self.eliminar_archivo_temporal()
        return True

    def obtener_contenido(self):
        return unicode(self.document().toPlainText())

    def ejecutar(self):
        ruta_personalizada = os.path.dirname(self.ruta_del_archivo_actual)
        #print "ejecutando texto desde widget editor"
        texto = self.obtener_contenido()
        texto = "from __future__ import print_function\n" + texto

        #texto = self.editor.obtener_texto_sanitizado(self)

        # elimina cabecera de encoding.
        contenido = re.sub('coding\s*:\s*', '', texto)
        contenido = contenido.replace('import pilasengine', '# PRINT HOOK')

        #contenido = contenido.replace("# PRINT HOOK", 'from __future__ import print_function')
        contenido = contenido.replace('pilas = pilasengine.iniciar', 'pilas.reiniciar')

        for x in contenido.split('\n'):
            if "__file__" in x:
                contenido = contenido.replace(x, "# livecoding: " + x + "\n")

        sys.path.append(ruta_personalizada)
        self.interpreterLocals['sys'] = sys
        self.interpreterLocals['print'] = self.consola_lanas.interpreter.imprimir_en_pantalla

        # Muchos códigos personalizados necesitan cargar imágenes o sonidos
        # desde el directorio que contiene al archivo. Para hacer esto posible,
        # se llama a la función "pilas.utils.agregar_ruta_personalizada" con el
        # path al directorio que representa el script. Así la función "obtener_ruta_al_recurso"
        # puede evaluar al directorio del script en busca de recursos también.
        if ruta_personalizada:
            ruta_personalizada = ruta_personalizada.replace('\\', '/')
            agregar_ruta_personalizada = 'pilas.utils.agregar_ruta_personalizada("%s")' %(ruta_personalizada)
            contenido = contenido.replace('pilas.reiniciar(', agregar_ruta_personalizada+'\n'+'pilas.reiniciar(')

        modulos_a_recargar = [x for x in self.interpreterLocals.values()
                                    if inspect.ismodule(x)
                                    and x.__name__ not in ['pilasengine', 'inspect']
                                    and 'pilasengine.' not in x.__name__]

        for m in modulos_a_recargar:
            reload(m)

        self.cantidad_ejecuciones += 1

        # Limpia la consola a partir de la primer ejecucion luego de iniciar.
        if self.cantidad_ejecuciones > 1:
            self.main.limpiar_interprete()

        try:
            exec(contenido, self.interpreterLocals)
        except Exception, e:
            self.consola_lanas.insertar_error_desde_exception(e)
            self.ventana_interprete.mostrar_el_interprete()
            #self.marcar_error_en_la_linea(10, "pepepe")

        self.signal_ejecutando.emit()

    def cuando_selecciona_actor_por_indice(self, indice):
        capturar_actor = "actor = pilas.obtener_actor_por_indice(" + str(indice) + ")"
        resaltar = "actor.transparencia = [50, 0] * 3, 0.1"
        exec(capturar_actor, self.interpreterLocals)
        exec(resaltar, self.interpreterLocals)
        self.consola_lanas.insertar_mensaje("# Creando la referencia 'actor': ")


if __name__ == '__main__':
    from PyQt4.QtGui import QApplication
    app = QApplication(sys.argv)
    weditor = WidgetEditor()
    weditor.show()
    app.exec_()