Esempio n. 1
0
class Editor(QPlainTextEdit):
    """
    Code Editor widget. Extends QPlainTextEdit to
    provide (through separate modules):
    - Line Number Area
    - Syntax Highlighting
    - Autocompletion (of Python code)
    - Shortcuts for code editing
    - Custom Context Menu
    - Signals for connecting the Editor to other
        UI elements.
    """
    wrap_types = [
        '\'',
        '"',
        '[',
        ']',
        '(',
        ')',
        '{',
        '}',
    ]

    wrap_signal = Signal(str)
    uuid_signal = Signal(str)
    return_signal = Signal(QKeyEvent)
    focus_in_signal = Signal()
    focus_out_signal = Signal()
    post_key_pressed_signal = Signal(QKeyEvent)
    wheel_signal = Signal(QWheelEvent)
    key_pressed_signal = Signal(QKeyEvent)
    shortcut_signal = Signal(QKeyEvent)
    resize_signal = Signal(QResizeEvent)
    context_menu_signal = Signal(QMenu)
    tab_signal = Signal()
    home_key_signal = Signal()
    relay_clear_output_signal = Signal()
    editingFinished = Signal()
    text_changed_signal = Signal()

    def __init__(self,
                 parent=None,
                 handle_shortcuts=True,
                 uid=None,
                 init_features=True):
        super(Editor, self).__init__(parent)
        self.setObjectName('Editor')
        self.setAcceptDrops(True)

        DEFAULT_FONT = constants.DEFAULT_FONT
        df = 'PYTHONEDITOR_DEFAULT_FONT'
        if os.getenv(df) is not None:
            DEFAULT_FONT = os.environ[df]
        font = QFont(DEFAULT_FONT)
        font.setPointSize(10)
        self.setFont(font)
        self.setMouseTracking(True)
        self.setCursorWidth(2)
        self.setStyleSheet("""
        QToolTip {
            color: #F6F6F6;
            background-color: rgb(38, 38, 38);
        }

        QPlainTextEdit {
            color: #F6F6F6;
            background-color:#1e1e1e;
            border: 1.5px solid #2b2b2b;
            border-radius: 8px;
        }

        """)

        # instance variables
        self.setLineWrapMode(QPlainTextEdit.NoWrap)
        if uid is None:
            uid = str(uuid.uuid4())
        self.shortcut_overrode_keyevent = False
        self._changed = False
        self.autocomplete_overriding = False
        self._handle_shortcuts = handle_shortcuts
        self._features_initialised = False
        self._key_pressed = False
        self.last_key_pressed = ''

        self.emit_text_changed = True
        self.textChanged.connect(self._handle_textChanged)

        linenumberarea.LineNumberArea(self)

        if init_features:
            self.init_features()

    def init_features(self):
        """
        Initialise custom Editor features.
        """
        if self._features_initialised:
            return
        self._features_initialised = True

        # QSyntaxHighlighter causes
        # textChanged to be emitted,
        # which we don't want.
        self.emit_text_changed = False
        syntaxhighlighter.Highlight(self.document(), self)

        def set_text_changed_enabled():
            self.emit_text_changed = True

        QTimer.singleShot(0, set_text_changed_enabled)

        CM = contextmenu.ContextMenu
        self.contextmenu = CM(self)

        # TODO: add a new autocompleter
        # that uses DirectConnection.
        self.autocomplete_overriding = True
        AC = autocompletion.AutoCompleter
        self.autocomplete = AC(self)

        if self._handle_shortcuts:
            actions.Actions(editor=self)
            shortcuts.ShortcutHandler(editor=self)

    def _handle_textChanged(self):
        self._changed = True

        # emit custom textChanged when desired.
        if self.emit_text_changed:
            self.text_changed_signal.emit()

    def setTextChanged(self, state=True):
        self._changed = state

    def replace_text(self, text):
        """
        Set the text programmatically
        but allow an undo. Works around
        setPlainText automatically
        resetting the undo stack.
        """
        tc = self.textCursor()
        tc.beginEditBlock()
        tc.select(tc.Document)
        tc.removeSelectedText()
        self.appendPlainText(text)
        tc.endEditBlock()

    def setPlainText(self, text):
        """
        Override original method to prevent
        textChanged signal being emitted.
        WARNING: textCursor can still be used
        to setPlainText.
        """
        self.emit_text_changed = False
        super(Editor, self).setPlainText(text)
        self.emit_text_changed = True

    def insertPlainText(self, text):
        """
        Override original method to prevent
        textChanged signal being emitted.
        """
        self.emit_text_changed = False
        super(Editor, self).insertPlainText(text)
        self.emit_text_changed = True

    def appendPlainText(self, text):
        """
        Override original method to prevent
        textChanged signal being emitted.
        """
        self.emit_text_changed = False
        super(Editor, self).appendPlainText(text)
        self.emit_text_changed = True

    def focusInEvent(self, event):
        """
        Emit a signal when focusing in a window.
        When there used to be an editor per tab,
        this would work well to check that the tab's
        contents had not been changed. Now, we'll
        also want to signal from the tab switched
        signal.
        """
        FR = Qt.FocusReason
        # ignore PopupFocusReason as the
        # autocomplete QListView triggers it.
        ignored_reasons = [
            FR.PopupFocusReason,
        ]
        if event.reason() not in ignored_reasons:
            self.focus_in_signal.emit()
        super(Editor, self).focusInEvent(event)

    def focusOutEvent(self, event):
        if self._changed:
            self.editingFinished.emit()

        # emit text changed to store the
        # latest text within the tab
        self.text_changed_signal.emit()

        FR = Qt.FocusReason
        ignored_reasons = [
            FR.PopupFocusReason,
        ]
        if event.reason() not in ignored_reasons:
            self.focus_out_signal.emit()

        super(Editor, self).focusOutEvent(event)

    def resizeEvent(self, event):
        """
        Emit signal on resize so that the
        LineNumberArea has a chance to update.
        """
        super(Editor, self).resizeEvent(event)
        self.resize_signal.emit(event)

    def keyPressEvent(self, event):
        """
        Emit signals for key events
        that QShortcut cannot override.
        """
        self._key_pressed = True

        if not self.hasFocus():
            event.ignore()
            return

        # print('Editor: {!r} has been pressed.'.format(event.text()))
        if self.autocomplete_overriding:
            # let the autocomplete handle the
            # key press (i.e. complete the text)
            self.key_pressed_signal.emit(event)
            return

        self.shortcut_overrode_keyevent = False
        self.shortcut_signal.emit(event)
        if self.shortcut_overrode_keyevent:
            event.accept()
            return

        # print('Editor: {!r} will be entered.'.format(event.text()))
        super(Editor, self).keyPressEvent(event)
        self.post_key_pressed_signal.emit(event)

    def keyReleaseEvent(self, event):
        self._key_pressed = False
        if not isinstance(self, Editor):
            # when the key released is F5
            # (reload app)
            return
        self.autocomplete_overriding = True
        super(Editor, self).keyReleaseEvent(event)

    def contextMenuEvent(self, event):
        """
        Creates a standard context menu
        and emits it for futher changes
        and execution elsewhere.
        """
        menu = self.createStandardContextMenu()
        self.context_menu_signal.emit(menu)

    def event(self, event):
        """
        Drop to open files implemented as a filter
        instead of dragEnterEvent and dropEvent
        because it is the only way to make it work
        on windows.
        """
        if event.type() == event.DragEnter:
            mimeData = event.mimeData()
            if mimeData.hasUrls():
                event.accept()
                return True
        if event.type() == event.Drop:
            mimeData = event.mimeData()
            if mimeData.hasUrls():
                event.accept()
                urls = mimeData.urls()
                self.drop_files(urls)
                return True
        try:
            return super(Editor, self).event(event)
        except TypeError:
            return False

    def drop_files(self, urls):
        """
        When dragging and dropping files onto the
        editor from a source with urls (file paths),
        if there are tabs, open the files in new
        tabs. If the tabs are not present just insert
        the text into the editor.
        """
        if self._handle_shortcuts:
            # if we're handling shortcuts
            # it means there are no tabs.
            # just insert the text
            text_list = []
            for url in urls:
                path = url.toLocalFile()
                with open(path, 'r') as f:
                    text_list.append(f.read())

            self.textCursor().insertText('\n'.join(text_list))
        else:
            tabeditor = self.parent()
            for url in urls:
                path = url.toLocalFile()
                actions.open_action(tabeditor.tabs, self, path)

    def wheelEvent(self, event):
        """
        Restore focus and, if ctrl held, emit signal
        """
        self.setFocus(Qt.MouseFocusReason)
        vertical = Qt.Orientation.Vertical
        is_vertical = (event.orientation() == vertical)
        CTRL = Qt.ControlModifier
        ctrl_held = (event.modifiers() == CTRL)
        if ctrl_held and is_vertical:
            return self.wheel_signal.emit(event)
        super(Editor, self).wheelEvent(event)

    def insertFromMimeData(self, mimeData):
        """
        Override to emit text_changed_signal
        (which triggers autosave) when text
        is pasted or dragged in.
        """
        self.text_changed_signal.emit()
        super(Editor, self).insertFromMimeData(mimeData)

    def showEvent(self, event):
        """
        Override to automatically set the
        focus on the editor using
        PopupFocusReason which won't
        trigger the autosave.
        """
        super(Editor, self).showEvent(event)
        self.setFocus(Qt.PopupFocusReason)
Esempio n. 2
0
class Terminal(QPlainTextEdit):
    """ Output text display widget """
    link_activated = Signal(str)

    def __init__(self):
        super(Terminal, self).__init__()

        self.setObjectName('Terminal')
        self.setWindowFlags(Qt.WindowStaysOnTopHint)
        self.setReadOnly(True)
        self.destroyed.connect(self.stop)
        font = QFont(DEFAULT_FONT)
        font.setPointSize(10)
        self.setFont(font)

        if os.getenv(STARTUP) == '1':
            self.setup()
        else:
            QTimer.singleShot(0, self.setup)

    @Slot(str)
    def receive(self, text):
        try:
            textCursor = self.textCursor()
            if bool(textCursor):
                self.moveCursor(QTextCursor.End)
        except Exception:
            pass
        self.insertPlainText(text)

    def stop(self):
        for stream in sys.stdout, sys.stderr:
            if hasattr(stream, 'reset'):
                stream.reset()

    def setup(self):
        """
        Checks for an existing stream wrapper
        for sys.stdout and connects to it. If
        not present, creates a new one.
        TODO:
        The FnRedirect sys.stdout is always active.
        With a singleton object on a thread,
        that reads off this stream, we can make it
        available to Python Editor even before opening
        the panel.
        """
        if hasattr(sys.stdout, '_signal'):
            self.speaker = sys.stdout._signal
        else:
            self.speaker = streams.Speaker()
            sys.stdout = streams.SESysStdOut(sys.stdout, self.speaker)
            sys.stderr = streams.SESysStdErr(sys.stderr, self.speaker)

        self.speaker.emitter.connect(self.receive)

    def mousePressEvent(self, event):
        if not hasattr(self, 'anchorAt'):
            # pyqt doesn't use anchorAt
            return super(Terminal, self).mousePressEvent(event)

        if (event.button() == Qt.LeftButton):
            # TODO: this is for clicking on links, and
            # currently nothing receives the signal.
            clickedAnchor = self.anchorAt(event.pos())
            if clickedAnchor:
                self.link_activated.emit(clickedAnchor)

        elif (event.button() == Qt.RightButton):
            menu = self.createStandardContextMenu()
            path_in_line = self.path_in_line(self.line_from_event(event))
            if path_in_line:

                def _goto():
                    goto(path_in_line)

                menu.addAction('Goto {0}'.format(path_in_line), _goto)
            menu.addAction('Parse Last Traceback', self.parse_last_traceback)
            menu.exec_(QCursor().pos())

        super(Terminal, self).mousePressEvent(event)

    def line_from_event(self, event):
        pos = event.pos()
        cursor = self.cursorForPosition(pos)
        cursor.select(cursor.BlockUnderCursor)
        selection = cursor.selection()
        text = selection.toPlainText().strip()
        return text

    def path_in_line(self, text):
        """
        Parse the line under the cursor
        to see if it contains a path to a
        file. If it does, return it.
        """
        pattern = re.compile(r'([\w\-\.\/\\]+)(", line )(\d+)')
        path = ''
        for fp, _, lineno in re.findall(pattern, text):
            return ':'.join([fp, lineno])
        return None

    def parse_last_traceback(self):
        tb = self.toPlainText().split('Traceback')[-1]
        pattern = re.compile(r'(File ")([\w\.\/]+)(", line )(\d+)')
        text = ''
        for _, fp, _, lineno in re.findall(pattern, tb):
            text += 'sublime ' + ':'.join([fp, lineno])
            text += '\n'

        print(text)
        QClipboard().setText(text)
Esempio n. 3
0
class CloseTabButton(QAbstractButton):
    close_clicked_signal = Signal(int)
    def __init__(self, parent=None, side=QTabBar.RightSide):
        super(CloseTabButton, self).__init__(parent=parent)
        self.side = side
        self.setFocusPolicy(Qt.NoFocus)
        self.setCursor(Qt.ArrowCursor)
        self.setToolTip('Close Tab')
        self.resize(self.sizeHint())

    def paintEvent(self, event):
        p = QPainter(self)
        opt = QStyleOption()
        try:
            opt.init(self)
        except AttributeError:
            # pyside 1/python 2.7
            opt.initFrom(self)
        opt.state |= QStyle.State_AutoRaise
        if (self.isEnabled() and self.underMouse() and not self.isChecked() and not self.isDown()):
            opt.state |= QStyle.State_Raised
        if (self.isChecked()):
            opt.state |= QStyle.State_On
        if (self.isDown()):
            opt.state |= QStyle.State_Sunken
        tb = self.parent()
        if isinstance(tb, QTabBar):
            index = tb.currentIndex()
            if (tb.tabButton(index, self.side) == self):
                opt.state |= QStyle.State_Selected

        self.style().drawPrimitive(QStyle.PE_IndicatorTabClose, opt, p, self)

    def sizeHint(self):
        self.ensurePolished()
        width = self.style().pixelMetric(QStyle.PM_TabCloseIndicatorWidth)
        height = self.style().pixelMetric(QStyle.PM_TabCloseIndicatorHeight)
        return QSize(width, height)

    def enterEvent(self, event):
        if self.isEnabled():
            self.update()
        QAbstractButton.enterEvent(self, event)

    def leaveEvent(self, event):
        if self.isEnabled():
            self.update()
        QAbstractButton.leaveEvent(self, event)

    def mousePressEvent(self, event):
        super(CloseTabButton, self).mousePressEvent(event)
        if event.button() == Qt.LeftButton:
            parent_pos = self.mapToParent(event.pos())
            index = self.parent().tabAt(parent_pos)
            self._pressed_index = index

    def mouseReleaseEvent(self, event):
        super(CloseTabButton, self).mouseReleaseEvent(event)
        if event.button() == Qt.LeftButton:
            parent_pos = self.mapToParent(event.pos())
            index = self.parent().tabAt(parent_pos)
            if index == self._pressed_index:
                self.close_clicked_signal.emit(index)
                self._pressed_index = -2
Esempio n. 4
0
class TabEditor(QWidget):
    """
    A psuedo-QTabWidget that contains
    a QTabBar and a single Editor.
    """
    tab_switched_signal = Signal()

    def __init__(self, parent=None):
        super(TabEditor, self).__init__(parent)
        if parent is not None:
            self.setParent(parent)

        self.setLayout(
            QVBoxLayout(self)
        )
        self.layout().setContentsMargins(0,0,0,0)

        self.tab_widget = QWidget()
        twl = QHBoxLayout(
            self.tab_widget
        )
        self.tab_widget_layout = twl
        self.tab_widget_layout.setContentsMargins(
            0,0,0,0
        )

        lb = QToolButton()
        self.tab_left_button = lb
        lb.setArrowType(Qt.LeftArrow)
        lb.setAutoRaise(True)
        lb.setToolTip('Go to previous tab.')
        self.tab_widget_layout.addWidget(lb)
        self.tab_left_button.clicked.connect(self.tab_left)

        self.tabs = Tabs()
        self.tab_widget_layout.addWidget(self.tabs)

        rb = QToolButton()
        self.tab_right_button = rb
        rb.setArrowType(Qt.RightArrow)
        rb.setAutoRaise(True)
        rb.setToolTip('Go to next tab.')
        self.tab_widget_layout.addWidget(rb)
        self.tab_right_button.clicked.connect(self.tab_right)

        # add corner buttons
        tb = QToolButton()
        self.tab_list_button = tb
        tb.setArrowType(Qt.DownArrow)
        tb.setToolTip(
            'Click for a list of tabs.'
        )
        tb.setAutoRaise(True)
        self.tab_list_button.clicked.connect(
            self.show_tab_menu
        )

        nb = QToolButton()
        self.new_tab_button = nb
        nb.setToolTip(
            'Click to add a new tab.'
        )
        nb.setText('+')
        nb.setAutoRaise(True)
        self.new_tab_button.clicked.connect(
            self.new_tab
        )

        for button in [
                self.new_tab_button,
                self.tab_list_button
            ]:
            self.tab_widget_layout.addWidget(button)

        self.editor = editor.Editor(
            parent=self,
            handle_shortcuts=False
        )

        for widget in self.tab_widget, self.editor:
            self.layout().addWidget(widget)

        # Give the autosave a chance to load all
        # tabs before connecting signals between
        # tabs and editor.
        QTimer.singleShot(
            0,
            self.connect_signals
        )

        self.setStyleSheet(TAB_STYLESHEET)

    def connect_signals(self):
        """
        We probably want to run this after tabs
        are loaded.
        """
        self.tabs.currentChanged.connect(
            self.set_editor_contents
        )
        self.tabs.currentChanged.connect(
            self.check_hide_arrows
        )
        self.tabs.tab_close_signal.connect(
            self.empty_if_last_tab_closed
        )

        self.editor.cursorPositionChanged.connect(
            self.store_cursor_position
        )
        self.editor.selectionChanged.connect(
            self.store_selection
        )

        # this is something we're going to want
        # only when tab already set (and not
        # when switching)
        self.editor.text_changed_signal.connect(
            self.save_text_in_tab
        )

    def check_hide_arrows(self, index):
        if index == 0:
            self.tab_left_button.hide()
        elif index == self.tabs.count()-1:
            self.tab_right_button.hide()
        else:
            if not self.tab_right_button.isVisible():
                self.tab_right_button.show()
            if not self.tab_left_button.isVisible():
                self.tab_left_button.show()

    def tab_left(self):
        i = self.tabs.currentIndex()-1
        if i >= 0:
            self.tabs.setCurrentIndex(i)

    def tab_right(self):
        i = self.tabs.currentIndex()+1
        if i <= self.tabs.count():
            self.tabs.setCurrentIndex(i)

    def show_tab_menu(self):
        """Show a QComboBox with a list
        of all the tabs, allowing you to
        scroll through all tabs easily and
        search by name."""
        current_index = self.tabs.currentIndex()
        tab_list = [self.tabs.tabText(i)
        for i in range(self.tabs.count())]

        # FIXME: this doesn't show up in the right place on windows - it shows up offscreen.
        # TODO: would look quite nice to embed it and have it animate in on hover, like the "advanced search" feature on assetimporter2
        class TabCombo(QComboBox):
            def __init__(self, parent=None):
                super(TabCombo, self).__init__(parent)
                self.setWindowFlags(Qt.FramelessWindowHint)
                self.setSizeAdjustPolicy(QComboBox.AdjustToContents)
                self.setEditable(True)
            def focusOutEvent(self, event):
                super(TabCombo, self).focusOutEvent(event)
                if event.reason() == Qt.FocusReason.ActiveWindowFocusReason:
                    self.close()
            def keyPressEvent(self, event):
                super(TabCombo, self).keyPressEvent(event)
                if event.key() == Qt.Key_Escape:
                    self.close()

        self._tab_combo = TabCombo()
        self._tab_combo.addItems(tab_list)
        self._tab_combo.adjustSize()
        self._tab_combo.setCurrentIndex(current_index)

        self._tab_combo.show()
        self._tab_combo.resize(200, 30)
        self._tab_combo.move(QCursor().pos())
        self._tab_combo.setFocus(Qt.MouseFocusReason)
        self._tab_combo.lineEdit().selectAll()
        self._tab_combo.currentIndexChanged.connect(
            self.tabs.setCurrentIndex)
        # self._tab_combo.lineEdit().setText('')
        # self._tab_combo.completer().complete()

    def new_tab(self, tab_name=None, tab_data={}):
        return self.tabs.new_tab(
            tab_name=tab_name,
            tab_data=tab_data
            )

    def close_current_tab(self):
        raise NotImplementedError

    @Slot(str)
    def empty_if_last_tab_closed(self, uid):
        if self.tabs.count() == 0:
            self.editor.setPlainText('')

    @Slot(int)
    def set_editor_contents(self, index):
        """
        Set editor contents to the data
        found in tab #index
        """
        data = self.tabs.tabData(index)

        if not data:
            # this will be a new
            # empty tab, ignore.
            return

        text = data['text']

        if text is None or not text.strip():
            path = data.get('path')
            if path is None:
                text = ''
            elif not os.path.isfile(path):
                text = ''
            else:
                with open(path, 'r') as f:
                    text = f.read()
                data['text'] = text

        # collect data before setting editor text
        cursor_pos = self.tabs.get('cursor_pos')
        selection = self.tabs.get('selection')

        self.editor.setPlainText(text)

        if cursor_pos is not None:
            block_pos, cursor_pos = cursor_pos

            # set first block visible
            cursor = self.editor.textCursor()
            self.editor.moveCursor(cursor.End)
            cursor.setPosition(block_pos)
            self.editor.setTextCursor(cursor)

            # restore cursor position
            cursor = self.editor.textCursor()
            cursor.setPosition(cursor_pos)
            self.editor.setTextCursor(cursor)

        if selection is not None:
            # TODO: this won't restore a selection
            # that starts from below and selects
            # upwards :( (yet)
            cursor = self.editor.textCursor()
            has, start, end = selection
            if has:
                cursor.setPosition(
                    start,
                    QTextCursor.MoveAnchor
                )
                cursor.setPosition(
                    end,
                    QTextCursor.KeepAnchor
                )
            self.editor.setTextCursor(cursor)
        self.editor.setFocus(Qt.MouseFocusReason)

        # for the autosave check_document_modified
        self.tab_switched_signal.emit()

    def store_cursor_position(self):
        editor = self.editor
        cursor = editor.textCursor()
        block = editor.firstVisibleBlock()
        pos = (block.position(), cursor.position())
        self.tabs.set_current_tab_property('cursor_pos', pos)

    def store_selection(self):
        tc = self.editor.textCursor()
        status = (tc.hasSelection(),
                  tc.selectionStart(),
                  tc.selectionEnd())
        self.tabs.set_current_tab_property('selection', status)

    def save_text_in_tab(self):
        """
        Store the editor's current text
        in the current tab.
        Strangely appears to be called twice
        on current editor's textChanged and
        backspace key...
        """
        if self.tabs.count() == 0:
            self.new_tab()

        saved = self.tabs.get('saved')
        original_text = self.tabs.get('original_text')
        if saved and not original_text:
            # keep original text in case
            # revert is required
            text = self.tabs.get_current_tab_property('text')
            self.tabs.set_current_tab_property('original_text', text)
            self.tabs.set_current_tab_property('saved', False)
            self.tabs.repaint()
        elif original_text is not None:
            text = self.editor.toPlainText()
            if original_text == text:
                self.tabs.set_current_tab_property('saved', True)
                self.tabs.repaint()

        self.tabs.set_current_tab_property('text', self.editor.toPlainText())
Esempio n. 5
0
class Tabs(QTabBar):
    """
    Make tabs fast by overriding the
    paintEvent to draw close buttons.

    FIXME: This is a GUI class. The data management
    should happen within a data model. This class
    should only serve as a view into that model,
    to permit other views to similarly display the
    model's content. This class shouldn't store any data
    at all, especially not tab content - all of that should be in
    the XMLModel, and this class should manage references to it.
    """
    pen = QPen()
    brush = QBrush()
    mouse_over_rect = False
    over_button = -1
    start_move_index = -1
    SIDE = QTabBar.ButtonPosition.RightSide

    # for autosave purposes:
    contents_saved_signal   = Signal(str)
    # in case we receive a ctrl+shift+w signal
    # to close the tab:
    tab_close_signal        = Signal(str)
    tab_renamed_signal      = Signal(
                                str,
                                str,
                                str,
                                str,
                                object
                              )
    tab_repositioned_signal = Signal(
                                int,
                                int
                              )
    reset_tab_signal        = Signal()

    def __init__(self, parent=None, model=None):
        super(Tabs, self).__init__(parent)

        # TODO: the beginning of the tabectomy, where I use this class as a view only,
        # and connect it properly to a model. currently, it does nothing.
        self._model = model

        self.tab_pressed = False
        self.pressed_uid = ''

        self.setMovable(True)
        self.setExpanding(False)
        self.setSelectionBehaviorOnRemove(QTabBar.SelectPreviousTab)

    def showEvent(self, event):
        QTabBar.showEvent(self, event)

        # now that we're visible, we can show close buttons too.
        self.add_close_button(self.currentIndex())
        # signals
        self.currentChanged.connect(self.add_close_button, Qt.QueuedConnection)

    def add_close_button(self, index):
        if not self.isVisible():
            return
        self.remove_close_buttons()
        if self.tabButton(index, self.SIDE) is None:
            button = CloseTabButton(self)
            self.setTabButton(index, self.SIDE, button)
            button.close_clicked_signal.connect(self.removeTab)

    def remove_close_buttons(self):
        current = self.currentIndex()
        for i in range(self.count()):
            if i == current:
                continue
            if self.tabButton(i, self.SIDE) is not None:
                self.setTabButton(i, self.SIDE, None)

    @Slot(str)
    def new_tab(self, tab_name=None, tab_data={}):
        """Creates a new tab."""
        index = self.currentIndex()+1

        if (tab_name is None
                or not tab_name):
            tab_name = 'Tab {0}'.format(index)

        self.insertTab(index, tab_name)
        data = {
            'uuid'  : str(uuid.uuid1()),
            'name'  : tab_name,
            'text'  : '',
            'path'  : '',
            'date'  : '',
            'saved' : False,
        }
        data.update(**tab_data)
        self.setTabData(index, data)
        self.setCurrentIndex(index)

    def get_current_tab_property(self, name):
        """Allow easy lookup for the current tab's data."""
        index = self.currentIndex()
        if index == -1:
            raise KeyError('No current tab.')

        data = self.tabData(index)
        if data is None:
            msg = 'No tab data available for index %i.' % index
            raise KeyError(msg)

        return data[name]

    def __getitem__(self, name):
        import traceback
        traceback.print_stack()
        print('Deprecated, use get_current_tab_property instead.')
        return self.get_current_tab_property(name)

    def get(self, name):
        try:
            return self.get_current_tab_property(name)
        except KeyError:
            return None

    def set_current_tab_property(self, name, value):
        """Easily set current tab's value."""
        if self.count() == 0:
            return
        index = self.currentIndex()
        tab_data = self.tabData(index)
        tab_data[name] = value
        self.setTabData(index, tab_data)

    def __setitem__(self, name, value):
        import traceback
        traceback.print_stack()
        print('Deprecated, use set_current_tab_property instead.')
        return self.set_current_tab_property(name, value)

    def tab_only_rect(self):
        """
        self.rect() without the <> buttons.
        """
        rect = self.rect()
        lB, rB = [
            c for c in self.children()
            if isinstance(c, QToolButton)
        ]
        side_button_width = lB.width()+rB.width()+15
        rect.adjust(0,0, -side_button_width, 0)
        return rect

    def event(self, event):
        # FIXME: I think this causes problems. surely this method can be refactored out.

        # FIXME: I mean look at this:
        try:
            # Check class (after reload, opening a new window, etc)
            # this can raise TypeError:
            # super(type, obj): obj must be an instance or subtype of type
            if not issubclass(Tabs, self.__class__):
                return False
        except TypeError:
            return False

        # FIXME: and this:
        try:
            QE = QEvent
        except AttributeError:
            return True

        if event.type() == QEvent.ToolTip:
            pos = self.mapFromGlobal(
                self.cursor().pos()
            )
            if self.rect().contains(pos):
                i = self.tabAt(pos)
                data = self.tabData(i)
                if data is not None:
                    path = data.get('path')
                    if path:
                        self.setTabToolTip(
                            i,
                            path
                        )
                    else:
                        self.setTabToolTip(
                            i,
                            data.get('name')
                        )

        return super(Tabs, self).event(event)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.tab_pressed = True
            pt = event.pos()
            i = self.tabAt(pt)
            self.pressedIndex = i
            self.start_move_index = i
            data = self.tabData(i)
            if data is not None:
                self.pressed_uid = data['uuid']
            self.dragStartPosition = pt

            # handle name edit still being visible
            if hasattr(self, 'name_edit'):
                try:
                    if self.name_edit.isVisible():
                        ti = self.name_edit.tab_index
                        if ti != i:
                            self.rename_tab()
                except RuntimeError:
                    # likely that the lineedit
                    # has been deleted
                    del self.name_edit

        # if not returned, handle clicking on tab
        super(Tabs, self ).mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.count() == 0:
            return

        # I think this was about keeping the tabdata in
        # sync with the tab indices.
        if event.buttons() == Qt.LeftButton:
            i = self.currentIndex()
            if (not hasattr(self, 'pressedIndex')
                or self.pressedIndex != i):
                self.pressedIndex = i
            data = self.tabData(self.pressedIndex)
            if data['uuid'] != self.pressed_uid:
                debug('wrong tab!')

        return super(Tabs, self).mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        self.tab_pressed = False
        i = self.tabAt(event.pos())
        if event.button() == Qt.LeftButton:
            if i == -1:
                i = self.currentIndex()
            if (i != self.start_move_index):
                self.tab_repositioned_signal.emit(
                    i,
                    self.start_move_index
                )

        elif event.button() == Qt.RightButton:
            menu = QMenu()

            rename = partial(self._show_name_edit, i)
            menu.addAction('Rename', rename)

            move_to_first = partial(self.move_to_first, i)
            menu.addAction('Move Tab to First', move_to_first)

            move_to_last = partial(self.move_to_last, i)
            menu.addAction('Move Tab to Last', move_to_last)

            close_tab_func = partial(self.removeTab, i)
            menu.addAction('Close Tab', close_tab_func)

            copy_file_path = partial(self.copy_tab_file_path, i)
            menu.addAction('Copy File Path', copy_file_path)

            # Other ideas (TODO)
            """
            menu.addAction('Close Other Tabs', )
            menu.addAction('Close Tabs to Right', )
            menu.addAction('Close Tabs to Left', )
            menu.addAction('Pin Tab', )
            """

            menu.exec_(QCursor().pos())

        elif event.button() == Qt.MiddleButton:
            if i != -1:
                self.removeTab(i)

        return super(Tabs, self).mouseReleaseEvent(event)

    def mouseDoubleClickEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.show_name_edit(event)
        return super(Tabs, self).mouseDoubleClickEvent(event)

    def show_name_edit(self, event):
        """
        Shows a QLineEdit widget where the tab
        text is, allowing renaming of tabs.
        """
        try:
            self.rename_tab()
        except RuntimeError: # likely that the lineedit has been deleted
            del self.name_edit
            return

        index = self.tabAt(event.pos())
        self._show_name_edit(index)

    def _show_name_edit(self, index):
        """
        Shows a QLineEdit widget where the tab
        text is, allowing renaming of tabs.
        """
        rect = self.tabRect(index)

        label = self.tabText(index)
        self.renaming_label = label

        self.tab_text = label
        self.tab_index = index

        self.name_edit = QLineEdit(self)
        self.name_edit.resize(
            rect.width(),
            rect.height()-7
        )
        self.name_edit.tab_index = index
        self.name_edit.tab_text = label
        self.name_edit.editingFinished.connect(
            self.rename_tab
        )
        self.name_edit.setText(
            label.strip()
        )
        self.name_edit.selectAll()
        self.name_edit.show()
        self.name_edit.raise_()
        p = rect.topLeft()
        self.name_edit.move(
            p.x(),
            p.y()+5
        )

        self.name_edit.setFocus(
            Qt.MouseFocusReason
        )

    def copy_tab_file_path(self, index):
        """
        Copy the current tab's file path
        (if it has one) to the clipboard.
        """
        data = self.tabData(index)
        path = data.get('path')
        if path is None or not path.strip():
            print('No file path for "{0}".'.format(
                data['name'])
            )
            return
        clipboard = QClipboard()
        clipboard.setText(path)
        print('Path copied to clipboard:')
        print(path)

    def move_to_first(self, index):
        """
        Move the current tab to the first position.
        """
        self.setCurrentIndex(0)
        self.moveTab(index, 0)
        self.setCurrentIndex(0)
        self.tab_repositioned_signal.emit(index, 0)

    def move_to_last(self, index):
        """
        Move the current tab to the last position.
        """
        last = self.count()-1
        self.setCurrentIndex(last)
        self.moveTab(index, last)
        self.setCurrentIndex(last)
        self.tab_repositioned_signal.emit(index, last)

    def rename_tab(self):
        """
        Sets the label of the tab the QLineEdit was
        spawned over.
        """
        if not (hasattr(self, 'name_edit')
                and self.name_edit.isVisible()):
            return

        self.name_edit.hide()

        label = self.name_edit.text().strip()
        if not bool(label):
            label = self.name_edit.tab_text

        index = self.name_edit.tab_index

        if self.renaming_label == label:
            return

        # FIXME: if the tab is not
        # positioned to the right,
        # this can cause a jump.
        self.setTabText(index, label)

        data = self.tabData(index)
        data['name'] = label
        self.tab_renamed_signal.emit(
            data['uuid'],
            data['name'],
            data['text'],
            str(index),
            data.get('path')
        )
        self.setTabData(index, data)

    @Slot()
    def remove_current_tab(self):
        self.removeTab(self.currentIndex())

    def removeTab(self, index):
        """
        The only way to remove a tab.

        If the tab's 'saved' property is not set to True,
        the user will be prompted to save.

        Setting 'saved' to True can only happen via:
        A) actually saving the file, or
        B) loading a stored tab which is just a saved file
        with no autosave text contents.

        If the tab is removed, its uuid is emitted which will notify
        the autosave handler to also remove the autosave.
        """
        data = self.tabData(index)
        if not isinstance(data, dict):
            return

        text = data.get('text')
        has_text = False
        if hasattr(text, 'strip'):
            has_text = bool(text.strip())

        if has_text:
            path = data.get('path')

            if path is None:
                # can't be sure it's saved
                # if it has no path
                data['saved'] = False
            elif not os.path.isfile(path):
                # can't be sure it's saved
                # if path doesn't exist
                data['saved'] = False
            else:
                with open(path, 'r') as f:
                    saved_text = f.read()
                if not saved_text == text:
                    data['saved'] = False

            saved = (data.get('saved') is True)
            if not saved:
                i = index
                if not self.prompt_user_to_save(i):
                    return

        super(Tabs, self).removeTab(index)

        self.tab_close_signal.emit(data['uuid'])

    def prompt_user_to_save(self, index):
        """ Ask the user if they wish to close
        a tab that has unsaved contents.
        """
        name = self.tabText(index)
        msg_box = QMessageBox()
        msg_box.setWindowTitle('Save changes?')
        msg_box.setText(
            '%s has not been saved to a file.'
            % name
        )
        msg_box.setInformativeText(
            'Do you want to save your changes?'
        )
        buttons = (
            msg_box.Save
            | msg_box.Discard
            | msg_box.Cancel
        )
        msg_box.setStandardButtons(buttons)
        msg_box.setDefaultButton(msg_box.Save)
        ret = msg_box.exec_()

        user_cancelled = (ret == msg_box.Cancel)

        if (ret == msg_box.Save):
            data = self.tabData(index)
            path = save.save(
                data['text'],
                data['path']
            )
            if path is None:
                user_cancelled = True

        if user_cancelled:
            return False

        return True
Esempio n. 6
0
class XMLModel(QStandardItemModel):
    def __init__(self):
        super(XMLModel, self).__init__()
        self.load_xml()
        self.itemChanged.connect(self.store_data)

    def load_xml(self):
        root, elements = autosavexml.parsexml('subscript')
        for element in elements:
            item1 = QStandardItem(element.attrib['name'])
            item2 = QStandardItem(element.text)
            item3 = QStandardItem(element.attrib['uuid'])
            self.appendRow([item1, item2, item3])

    def store_data(self, item):
        text = item.text()
        element = ETree.Element('subscript')
        element.text = text

    def flags(self, index):
        """
        https://www.qtcentre.org/threads/23258-How-to-reorder-items-in-QListView
        You still want the root index to accepts
        drops, just not the items.
        This will work if the root index is
        invalid (as it usually is). However,
        if you use setRootIndex you may have
        to compare against that index instead.
        """
        if index.isValid():
            return (Qt.ItemIsSelectable
                    | Qt.ItemIsEditable
                    | Qt.ItemIsDragEnabled
                    | Qt.ItemIsEnabled)

        return (Qt.ItemIsSelectable
                | Qt.ItemIsDragEnabled
                | Qt.ItemIsDropEnabled
                | Qt.ItemIsEnabled)

    def mimeTypes(self):
        return ['text/json']

    def mimeData(self, indexes):
        data = {}
        row = indexes[0].row()
        data['name'] = self.item(row, 0).text()
        data['text'] = self.item(row, 1).text()
        data['uuid'] = self.item(row, 2).text()
        data['row'] = row
        dragData = json.dumps(data, indent=2)
        mimeData = QMimeData()
        mimeData.setData('text/json', dragData)
        return mimeData

    def stringList(self):
        """ List of document names.
        """
        names = []
        for i in range(self.rowCount()):
            name = self.item(i, 0).text()
            print(name)
            names.append(name)
        return names

    row_moved = Signal(int, int)

    def dropMimeData(self, data, action, row, column, parent=None):
        dropData = json.loads(bytes(data.data('text/json')))
        take_row = dropData['row']
        items = self.takeRow(take_row)
        if take_row < row:
            row -= 1
        elif row == -1:
            row = self.rowCount()
        print(take_row, '->', row)
        self.insertRow(row, items)
        self.row_moved.emit(take_row, row)
        return True