0

I'm looking for some input on how to go about creating a horizontal Kanban-style board using PySide2. My app is a file browser where you select an item from the folder QTreeView on the left and the right view will populate with cards. The right side card view is where I'm stumped.

Here is my design goal:

enter image description here

My current wip implementation uses a QTreeView to display the cards - it's close but not exactly what I'm looking for. Currently, delegate draws the parents as pseudo headers and the children as cards. As you can see below, one of the problems with using QTreeView is that the children are listed vertically, rather than my preferred horizontal listing.

My current wip implementation:

enter image description here

I have a few of ideas on how to go about this:

  1. Use setIndexWidget() to add a QListView under each pseudo header parent item. I'm not sure if this is the intended use of this method or how to get model data to populate the list.
  2. Replace or cover the QListView with a QWidget that is dynamically populated with model data from the folder view's selection. This widget creates QLabels and QlistViews for each header item from the model. I feel like it's over complicating things and a solution modifying an existing view would probably be better in the long run.
  3. Use another view that I'm unaware of!

Any thoughts on how to go about building this? Does a vertical Kanban widget exist? Thanks!

Also, here are some other views that I've tried:

list

enter image description here

column view

enter image description here

Example Code:

import os
import sys
import collections

from PySide2 import QtWidgets, QtGui, QtCore


class MainWidget(QtWidgets.QWidget):

    def __init__(self, model_data):
        super(MainWidget, self).__init__()

        self.setMinimumSize(600, 500)

        # Model
        self.model_data = model_data
        self.folder_model = QtGui.QStandardItemModel()
        self._fill_model(model_data)

        # Folder view
        self.folders_view = QtWidgets.QTreeView()
        self.folders_view.setModel(self.folder_model)
        self.folders_view.expandAll()
        self.folders_view.setItemsExpandable(False)
        self.folders_view.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)

        # Files Delegate
        self.files_delegate = FilesItemDelegate()
        self.folders_view.setItemDelegate(self.files_delegate)

        # Layout
        self.main_layout = QtWidgets.QHBoxLayout()
        self.main_layout.addWidget(self.folders_view)
        self.main_layout.setContentsMargins(0, 0, 0, 0)

        self.setLayout(self.main_layout)

    def _fill_model(self, value, parent=None):
        if isinstance(value, collections.abc.Mapping):
            for key, val in sorted(value.items()):

                if key == 'meta_data':
                    pass
                else:
                    item = QtGui.QStandardItem(key)
                    item.setData(val['meta_data']['name'], QtCore.Qt.DisplayRole)
                    item.setData(val['meta_data']['item_type'], QtCore.Qt.UserRole + 1)

                    try:  # special data for major items
                        item.setData(val['meta_data']['major_number'], QtCore.Qt.UserRole)
                    except KeyError:
                        pass
                    try:  # special data for minor items
                        item.setData(val['meta_data']['comment'], QtCore.Qt.UserRole)
                    except KeyError:
                        pass

                    try:  # Add row under a parent item
                        parent.appendRow(item)
                        self._fill_model(value=val, parent=item)

                    except AttributeError:  # Add first item to model
                        self.folder_model.appendRow(item)
                        self._fill_model(value=val, parent=item)


class FilesItemDelegate(QtWidgets.QStyledItemDelegate):
    def __init__(self, parent=None):
        super(FilesItemDelegate, self).__init__(parent)

    def sizeHint(self, option, index):

        if index.data(role=QtCore.Qt.UserRole + 1) == 'major':

            size = super(FilesItemDelegate, self).sizeHint(option, index)

            size.setWidth(option.rect.width())
            size.setHeight(50)
            return size

        elif index.data(role=QtCore.Qt.UserRole + 1) == 'minor':

            size = super(FilesItemDelegate, self).sizeHint(option, index)

            if option.state & QtWidgets.QStyle.State_Selected:
                width = 250
            elif option.state & QtWidgets.QStyle.State_MouseOver:
                width = 250
            else:
                width = 200

            size.setWidth(width)
            size.setHeight(200)
            return size

        else:
            return super(FilesItemDelegate, self).sizeHint(option, index)

    def paint(self, painter, option, index):

        # Rect
        rect_item = option.rect

        # Background
        painter.setPen(QtCore.Qt.NoPen)

        # File component
        if index.data(role=QtCore.Qt.UserRole + 1) == 'major':

            # Rects:
            rect_header_icon = QtCore.QRect(
                rect_item.left() + 7,
                rect_item.top(),
                50,
                rect_item.height() - 3)
            rect_header_name = QtCore.QRect(
                rect_header_icon.right() + 8,
                rect_item.top(),
                rect_item.width() - rect_header_icon.width(),
                rect_item.height() * .666)
            rect_header_major = QtCore.QRect(
                rect_header_icon.right() + 8,
                rect_header_name.bottom(),
                rect_item.width() - rect_header_icon.width(),
                rect_item.height() * .333)

            if option.state & QtWidgets.QStyle.State_Selected:
                painter.setBrush(QtGui.QColor('#00000000'))
            elif option.state & QtWidgets.QStyle.State_MouseOver:
                painter.setBrush(QtGui.QColor('#00000000'))
            else:
                painter.setBrush(QtGui.QColor('#00000000'))
            painter.drawRect(option.rect)

            # Icon
            painter.save()
            painter.setBrush(QtGui.QColor('#262626'))
            painter.drawRect(rect_header_icon)
            painter.restore()

            # Primary Title
            painter.setPen(QtGui.QColor(100, 100, 100))

            # Name
            name_index = index.data(role=QtCore.Qt.DisplayRole)
            name_font = QtGui.QFont("Segoe UI", 13, QtGui.QFont.DemiBold | QtGui.QFont.NoAntialias)
            painter.setFont(name_font)
            QtWidgets.QApplication.style().drawItemText(painter, rect_header_name, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
                                                        QtWidgets.QApplication.palette(), True,
                                                        name_index)
            # Major number
            name_index = index.data(role=QtCore.Qt.UserRole)
            name_font = QtGui.QFont("Segoe UI", 13, QtGui.QFont.DemiBold | QtGui.QFont.NoAntialias)
            painter.setFont(name_font)
            QtWidgets.QApplication.style().drawItemText(painter, rect_header_major,
                                                        QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
                                                        QtWidgets.QApplication.palette(), True,
                                                        name_index)

        # Cards
        elif index.data(role=QtCore.Qt.UserRole + 1) == 'minor':

            # Rects
            rect_icon_target = QtCore.QRect(
                rect_item.left(),
                rect_item.top(),
                rect_item.width(),
                rect_item.height() - int(rect_item.height()/2)
            )
            rect_data_target = QtCore.QRect(
                rect_item.left(),
                rect_icon_target.bottom(),
                rect_item.width(),
                rect_item.height() - int(rect_item.height()/2)
            )

            pad_data_target = 15
            rect_name_target = QtCore.QRect(
                rect_item.left() + pad_data_target,
                rect_icon_target.bottom() + pad_data_target,
                rect_item.width() - pad_data_target,
                rect_data_target.height()/2 - pad_data_target
            )
            rect_comment_target = QtCore.QRect(
                rect_item.left() + pad_data_target,
                rect_name_target.bottom() + pad_data_target,
                rect_item.width() - pad_data_target,
                rect_data_target.height() - pad_data_target
            )

            # Image half
            painter.save()
            path = QtGui.QPainterPath()
            path.addRect(rect_icon_target)
            painter.setBrush(QtGui.QColor(90, 90, 90))
            painter.drawPath(path)
            painter.restore()

            # Data half
            painter.save()
            path = QtGui.QPainterPath()
            path.setFillRule(QtCore.Qt.WindingFill)
            path.addRect(rect_data_target)

            if option.state & QtWidgets.QStyle.State_Selected:
                painter.setBrush(QtGui.QColor(0, 149, 119))
            elif option.state & QtWidgets.QStyle.State_MouseOver:
                painter.setBrush(QtGui.QColor(100, 100, 100))
            else:
                painter.setBrush(QtGui.QColor(67, 67, 67))

            painter.drawPath(path.simplified())
            painter.restore()

            # Primary Title
            painter.setPen(QtGui.QColor(255, 255, 255))
            name_index = index.data(role=QtCore.Qt.DisplayRole)
            name_font = QtGui.QFont("Segoe UI", 13, QtGui.QFont.DemiBold | QtGui.QFont.NoAntialias)
            painter.setFont(name_font)
            QtWidgets.QApplication.style().drawItemText(painter, rect_name_target, QtCore.Qt.AlignLeft,
                                                        QtWidgets.QApplication.palette(), True,
                                                        name_index)
            # Comment
            painter.setPen(QtGui.QColor(255, 255, 255))
            comment_index = index.data(role=QtCore.Qt.UserRole)
            comment_font = QtGui.QFont("Segoe UI", 13, QtGui.QFont.DemiBold | QtGui.QFont.NoAntialias)
            painter.setFont(comment_font)
            QtWidgets.QApplication.style().drawItemText(painter, rect_comment_target, QtCore.Qt.AlignLeft,
                                                        QtWidgets.QApplication.palette(), True,
                                                        comment_index)

        else:
            return super(FilesItemDelegate, self).paint(painter, option, index)


def launch():
    model_data = {
        'low_poly': {
            '001': {'meta_data': {'name': '001', 'item_type': 'minor', 'comment': 'hey'}},
            '002': {'meta_data': {'name': '002', 'item_type': 'minor', 'comment': 'you'}},
            '003': {'meta_data': {'name': '003', 'item_type': 'minor', 'comment': 'guyyyyys'}},
            '004': {'meta_data': {'name': '004', 'item_type': 'minor', 'comment': "i'll"}},
            '005': {'meta_data': {'name': '005', 'item_type': 'minor', 'comment': 'be'}},
            '006': {'meta_data': {'name': '006', 'item_type': 'minor', 'comment': 'back'}},
            'meta_data': {'item_type': 'major', 'name': 'low_poly', 'major_number': '001'}},
        'high_poly': {
            '001': {'meta_data': {'name': '001', 'item_type': 'minor', 'comment': 'as'}},
            '002': {'meta_data': {'name': '002', 'item_type': 'minor', 'comment': 'you'}},
            '003': {'meta_data': {'name': '003', 'item_type': 'minor', 'comment': 'wish'}},
            '004': {'meta_data': {'name': '004', 'item_type': 'minor', 'comment': "lok"}},
            '005': {'meta_data': {'name': '005', 'item_type': 'minor', 'comment': 'tar'}},
            '006': {'meta_data': {'name': '006', 'item_type': 'minor', 'comment': 'ogar'}},
            'meta_data': {'item_type': 'major', 'name': 'high_poly', 'major_number': '002'}},
        'no_poly': {
            '001': {'meta_data': {'name': '001', 'item_type': 'minor', 'comment': 'this'}},
            '002': {'meta_data': {'name': '002', 'item_type': 'minor', 'comment': 'is'}},
            '003': {'meta_data': {'name': '003', 'item_type': 'minor', 'comment': 'my'}},
            '004': {'meta_data': {'name': '004', 'item_type': 'minor', 'comment': "boomstick"}},
            '005': {'meta_data': {'name': '005', 'item_type': 'minor', 'comment': 'good'}},
            '006': {'meta_data': {'name': '006', 'item_type': 'minor', 'comment': 'bye'}},
            'meta_data': {'item_type': 'major', 'name': 'high_poly', 'major_number': '003'}},
        'meta_data': {'item_type': 'asset', 'name': 'asset1'}
    }


    try:
        os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--enable-logging --log-level=3"
        os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"  # High dpi setting
        app = QtWidgets.QApplication(sys.argv)

    except:
        pass
    window = MainWidget(model_data)
    window.show()

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

    return window


if __name__ == "__main__":

    launch()
Mike Bourbeau
  • 481
  • 11
  • 29
  • I guess it makes sense to put together a code example in case someone would like to answer in that way so I'll set one up. In the mean time (to clarify) I'm looking for some guidance like "number 1 is possible by doing this then that" or "none of these ideas are worth pursuing, check out widget x and y" – Mike Bourbeau Jan 20 '20 at 00:19
  • 1
    I understand what you want but my style is to give a specific answer, that is, an example tested to analyze the different options, for example I have the idea of using QListView in IconMode with some modifications. – eyllanesc Jan 20 '20 at 00:23
  • @eyllanesc I've added the example code. It creates the files view window (the one with the parents displayed as headers and the children displayed as cards). – Mike Bourbeau Jan 21 '20 at 17:29

0 Answers0