eye icon indicating copy to clipboard operation
eye copied to clipboard

Extending splitter widget to behave like SublimeText3

Open brupelo opened this issue 6 years ago • 0 comments

Some basic notation first:

  • Splitter: It contains 1 or more slots
  • Slot: It contains 0 or more widgets
  • Widgets: It can be the child of a slot and it can be attached/detached between different slots

Right now the splitter is a really usable widget and it allows the user to create dynamic layouts by adding/removing slots using 3 basic options (split horizontally, split vertically, resizing the slot), it's a really flexible widget and is UI friendly.

That said, SublimeText splitter has really cool features you could borrow to make the current widget even more flexible:

  • Create layouts, one layout is a certain state of the splitter. Example1, Example2.
  • Moving slots around by dragging them with the mouse or by using shortcuts. Example3
  • Once you're using the concept of layout, the editor can save/restore the state of a layout at startup.

I think these features boost up coding productivity as you can easily switch between layouts when you're coding multiple components and you've got a general view of all parts of a task. For instance, if you're coding a standalone widget 1 window is good enough, if you're coding a widget with 1 single dependency 1x1, code from different packages layout mxn.

Here's some code that could help to improve the current splitter, code based on https://stackoverflow.com/questions/47267195/in-pyqt4-is-it-possible-to-detach-tabs-from-a-qtabwidget:

DetachableTab.py

from PyQt5.Qt import * # noqa

class TabBar(QTabBar):
    tab_detached = pyqtSignal(int, QPoint)
    tab_moved = pyqtSignal(int, int)
    tab_droped = pyqtSignal(str, int, QPoint)

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

        self.setAcceptDrops(True)
        self.setElideMode(Qt.ElideRight)
        self.setSelectionBehaviorOnRemove(QTabBar.SelectLeftTab)

        self.drag_start_pos = QPoint()
        self.drag_droped_pos = QPoint()
        self.mouse_cursor = QCursor()
        self.drag_initiated = False

    def mouseDoubleClickEvent(self, event):
        event.accept()
        self.tab_detached.emit(self.tabAt(
            event.pos()), self.mouse_cursor.pos())

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drag_start_pos = event.pos()

        self.drag_droped_pos.setX(0)
        self.drag_droped_pos.setY(0)
        self.drag_initiated = False

        QTabBar.mousePressEvent(self, event)

    def mouseMoveEvent(self, event):
        if not self.drag_start_pos.isNull() and ((event.pos() - self.drag_start_pos).manhattanLength() < QApplication.startDragDistance()):
            self.drag_initiated = True

        if (((event.buttons() & Qt.LeftButton)) and self.drag_initiated):
            finishMoveEvent = QMouseEvent(QEvent.MouseMove, event.pos(
            ), Qt.NoButton, Qt.NoButton, Qt.NoModifier)
            QTabBar.mouseMoveEvent(self, finishMoveEvent)

            drag = QDrag(self)
            md = QMimeData()
            md.setData('action', QByteArray().append('application/tab-detach'))
            drag.setMimeData(md)

            pixmap = self.parentWidget().currentWidget().grab()
            target_pixmap = QPixmap(pixmap.size())
            target_pixmap.fill(Qt.transparent)
            painter = QPainter(target_pixmap)
            painter.setOpacity(0.85)
            painter.drawPixmap(0, 0, pixmap)
            painter.end()
            drag.setPixmap(target_pixmap)

            drop_action = drag.exec_(
                Qt.MoveAction | Qt.CopyAction)

            if self.drag_droped_pos.x() != 0 and self.drag_droped_pos.y() != 0:
                drop_action = Qt.MoveAction

            if drop_action == Qt.IgnoreAction:
                event.accept()
                self.tab_detached.emit(self.tabAt(
                    self.drag_start_pos), self.mouse_cursor.pos())

            elif drop_action == Qt.MoveAction:
                if not self.drag_droped_pos.isNull():
                    event.accept()
                    self.tab_moved.emit(self.tabAt(
                        self.drag_start_pos), self.tabAt(self.drag_droped_pos))
        else:
            QTabBar.mouseMoveEvent(self, event)

    def dragEnterEvent(self, event):
        md = event.mimeData()
        md_str = str(md.data('action'), encoding='utf-8')
        formats = md.formats()

        if 'action' in formats and md_str == 'application/tab-detach':
            event.acceptProposedAction()

        QTabBar.dragMoveEvent(self, event)

    def dropEvent(self, event):
        self.drag_droped_pos = event.pos()
        QTabBar.dropEvent(self, event)

    def detached_tab_drop(self, name, drop_pos):
        tab_drop_pos = self.mapFromGlobal(drop_pos)
        index = self.tabAt(tab_drop_pos)
        self.tab_droped.emit(name, index, drop_pos)


class WindowDropFilter(QObject):
    signal_droped = pyqtSignal(QPoint)

    def __init__(self):
        QObject.__init__(self)
        self.last_event = None

    def eventFilter(self, obj, event):
        if self.last_event == QEvent.Move and event.type() == 173:
            mouse_cursor = QCursor()
            drop_pos = mouse_cursor.pos()
            self.signal_droped.emit(drop_pos)
            self.last_event = event.type()
            return True
        else:
            self.last_event = event.type()
            return False


class DetachedTab(QMainWindow):
    signal_closed = pyqtSignal(QWidget, str, QIcon)
    signal_droped = pyqtSignal(str, QPoint)

    def __init__(self, name, content_widget):
        super().__init__()

        self.setObjectName(name)
        self.setWindowTitle(name)

        self.content_widget = content_widget
        self.setCentralWidget(self.content_widget)
        self.content_widget.show()

        self.window_drop_filter = WindowDropFilter()
        self.installEventFilter(self.window_drop_filter)
        self.window_drop_filter.signal_droped.connect(self.on_signal_droped)

    def on_signal_droped(self, drop_pos):
        self.signal_droped.emit(self.objectName(), drop_pos)

    def closeEvent(self, event):
        self.signal_closed.emit(
            self.content_widget, self.objectName(), self.windowIcon())


class DetachableTabWidget(QTabWidget):

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

        self.tab_bar = TabBar(self)
        self.tab_bar.tab_detached.connect(self.detachTab)
        self.tab_bar.tab_moved.connect(self.moveTab)
        self.tab_bar.tab_droped.connect(self.detached_tab_drop)

        self.setTabBar(self.tab_bar)

        self.detached_tabs = {}

        qApp.aboutToQuit.connect(self.close_detached_tabs)

    def setMovable(self, movable):
        pass

    def moveTab(self, fromIndex, toIndex):
        widget = self.widget(fromIndex)
        icon = self.tabIcon(fromIndex)
        text = self.tabText(fromIndex)

        self.removeTab(fromIndex)
        self.insertTab(toIndex, widget, icon, text)
        self.setCurrentIndex(toIndex)

    def detachTab(self, index, point):
        name = self.tabText(index)
        icon = self.tabIcon(index)
        if icon.isNull():
            icon = self.window().windowIcon()
        content_widget = self.widget(index)

        try:
            content_widget_rect = content_widget.frameGeometry()
        except AttributeError:
            return

        detached_tab = DetachedTab(name, content_widget)
        detached_tab.setWindowModality(Qt.NonModal)
        detached_tab.setWindowIcon(icon)
        detached_tab.setGeometry(content_widget_rect)
        detached_tab.signal_closed.connect(self.attachTab)
        detached_tab.signal_droped.connect(self.tab_bar.detached_tab_drop)
        detached_tab.move(point)
        detached_tab.show()

        self.detached_tabs[name] = detached_tab

    def attachTab(self, content_widget, name, icon, insert_at=None):
        content_widget.setParent(self)

        del self.detached_tabs[name]

        if not icon.isNull():
            try:
                tab_icon_pixmap = icon.pixmap(icon.availableSizes()[0])
                tab_icon_image = tab_icon_pixmap.toImage()
            except IndexError:
                tab_icon_image = None
        else:
            tab_icon_image = None

        if not icon.isNull():
            try:
                window_icon_pixmap = self.window().windowIcon().pixmap(
                    icon.availableSizes()[0])
                window_icon_image = window_icon_pixmap.toImage()
            except IndexError:
                window_icon_image = None
        else:
            window_icon_image = None

        if tab_icon_image == window_icon_image:
            if insert_at == None:
                index = self.addTab(content_widget, name)
            else:
                index = self.insertTab(insert_at, content_widget, name)
        else:
            if insert_at == None:
                index = self.addTab(content_widget, icon, name)
            else:
                index = self.insertTab(insert_at, content_widget, icon, name)

        if index > -1:
            self.setCurrentIndex(index)

    def remove_tab_by_name(self, name):
        attached = False
        for index in xrange(self.count()):
            if str(name) == str(self.tabText(index)):
                self.removeTab(index)
                attached = True
                break

        if not attached:
            for key in self.detached_tabs:
                if str(name) == str(key):
                    self.detached_tabs[key].signal_closed.disconnect()
                    self.detached_tabs[key].close()
                    del self.detached_tabs[key]
                    break

    def detached_tab_drop(self, name, index, drop_pos):
        if index > -1:
            content_widget = self.detached_tabs[name].content_widget
            icon = self.detached_tabs[name].windowIcon()

            self.detached_tabs[name].signal_closed.disconnect()
            self.detached_tabs[name].close()
            self.attachTab(content_widget, name, icon, index)
        else:
            tab_drop_pos = self.mapFromGlobal(drop_pos)

            if self.rect().contains(tab_drop_pos):
                if tab_drop_pos.y() < self.tab_bar.height() or self.count() == 0:
                    self.detached_tabs[name].close()

    def close_detached_tabs(self):
        listOfDetachedTabs = []

        for key in self.detached_tabs:
            listOfDetachedTabs.append(self.detached_tabs[key])

        for detached_tab in listOfDetachedTabs:
            detached_tab.close()


if __name__ == '__main__':
    import sys

    app = QApplication(sys.argv)

    mainWindow = QMainWindow()
    tabWidget = DetachableTabWidget()

    tab1 = QLabel('Test Widget 1')
    tabWidget.addTab(tab1, 'Tab1')

    tab2 = QLabel('Test Widget 2')
    tabWidget.addTab(tab2, 'Tab2')

    tab3 = QLabel('Test Widget 3')
    tabWidget.addTab(tab3, 'Tab3')

    tabWidget.show()
    mainWindow.setCentralWidget(tabWidget)
    mainWindow.show()

    try:
        exitStatus = app.exec_()
        sys.exit(exitStatus)
    except:
        pass

brupelo avatar Jun 04 '18 21:06 brupelo