diff --git a/.gitignore b/.gitignore index 1b296a9..44702c2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__ dist cover .coverage -build \ No newline at end of file +build +.idea/ diff --git a/pyblish_lite/app.css b/pyblish_lite/app.css index 253badf..4427d64 100644 --- a/pyblish_lite/app.css +++ b/pyblish_lite/app.css @@ -37,6 +37,11 @@ QListView { background: "transparent" } +QTreeView { + border: 0px; + background: "transparent" +} + QPushButton { width: 27px; height: 27px; diff --git a/pyblish_lite/delegate.py b/pyblish_lite/delegate.py index 2dfc6d6..d213977 100644 --- a/pyblish_lite/delegate.py +++ b/pyblish_lite/delegate.py @@ -119,6 +119,66 @@ def sizeHint(self, option, index): return QtCore.QSize(option.rect.width(), 20) +class Section(QtWidgets.QStyledItemDelegate): + """Generic delegate for section header""" + + def paint(self, painter, option, index): + """Paint text + _ + My label + + """ + + body_rect = QtCore.QRectF(option.rect) + + metrics = painter.fontMetrics() + + label_rect = QtCore.QRectF(option.rect.adjusted(0, 2, 0, -2)) + + assert label_rect.width() > 0 + + label = index.data(model.Label) + label = metrics.elidedText(label, + QtCore.Qt.ElideRight, + label_rect.width()) + + font_color = colors["idle"] + if not index.data(model.IsChecked): + font_color = colors["inactive"] + + # Maintain reference to state, so we can restore it once we're done + painter.save() + + # Draw label + painter.setFont(fonts["h4"]) + painter.setPen(QtGui.QPen(font_color)) + painter.drawText(label_rect, label) + + if option.state & QtWidgets.QStyle.State_MouseOver: + painter.fillRect(body_rect, colors["hover"]) + + if option.state & QtWidgets.QStyle.State_Selected: + painter.fillRect(body_rect, colors["selected"]) + + # Ok, we're done, tidy up. + painter.restore() + + def sizeHint(self, option, index): + return QtCore.QSize(option.rect.width(), 20) + + +class ItemAndSection(Item): + """Generic delegate for model items in proxy tree view""" + def paint(self, painter, option, index): + + model = index.model() + if model.is_header(index): + Section().paint(painter, option, index) + return + + super(ItemAndSection, self).paint(painter, option, index) + + class Artist(QtWidgets.QStyledItemDelegate): """Delegate used on Artist page""" diff --git a/pyblish_lite/model.py b/pyblish_lite/model.py index bd74e09..3bda46d 100644 --- a/pyblish_lite/model.py +++ b/pyblish_lite/model.py @@ -46,6 +46,7 @@ Label = QtCore.Qt.DisplayRole + 0 Families = QtCore.Qt.DisplayRole + 1 Icon = QtCore.Qt.DisplayRole + 13 +Order = QtCore.Qt.UserRole + 62 # The item has not been used IsIdle = QtCore.Qt.UserRole + 2 @@ -129,6 +130,7 @@ def __init__(self, parent=None): Actions: "actions", IsOptional: "optional", Icon: "icon", + Order: "order", # GUI-only data Type: "_type", diff --git a/pyblish_lite/tree.py b/pyblish_lite/tree.py new file mode 100644 index 0000000..945c74f --- /dev/null +++ b/pyblish_lite/tree.py @@ -0,0 +1,359 @@ + +import pyblish + +from .vendor import Qt +from Qt import QtWidgets, QtCore, __binding__ +from itertools import groupby + + +class Item(object): + """Base class for an Item in the Group By Proxy""" + def __init__(self): + self._parent = None + self._children = list() + + def parent(self): + return self._parent + + def addChild(self, node): + node._parent = self + self._children.append(node) + + def rowCount(self): + return len(self._children) + + def row(self): + + parent = self.parent() + if not parent: + return 0 + else: + return self.parent().children().index(self) + + def columnCount(self): + return 1 + + def child(self, row): + return self._children[row] + + def children(self): + return self._children + + def data(self, role=QtCore.Qt.DisplayRole): + return None + + +class ProxyItem(Item): + def __init__(self, source_index): + super(ProxyItem, self).__init__() + self.source_index = source_index + + def data(self, role=QtCore.Qt.DisplayRole): + return self.source_index.data(role) + + +class ProxySectionItem(Item): + def __init__(self, label): + super(ProxySectionItem, self).__init__() + self.label = "{0}".format(label) + + def data(self, role=QtCore.Qt.DisplayRole): + + if role == QtCore.Qt.DisplayRole: + return self.label + + elif role == QtCore.Qt.FontRole: + font = QtWidgets.QFont() + font.setPointSize(10) + font.setWeight(900) + return font + + elif role == QtCore.Qt.TextColorRole: + return QtWidgets.QColor(50, 20, 20) + + elif role == QtCore.Qt.BackgroundColorRole: + return QtWidgets.QColor(220, 220, 220) + + +class Proxy(QtWidgets.QAbstractProxyModel): + """Proxy that groups by based on a specific role + + This assumes the source data is a flat list and not a tree. + + """ + + def __init__(self): + super(Proxy, self).__init__() + self.root = Item() + self.group_role = QtCore.Qt.DisplayRole + + def set_group_role(self, role): + self.group_role = role + + def groupby_key(self, source_index): + """Returns the data to group by. + + Override this in subclasses to group by customized data instead of + by simply the currently set group role. + + Args: + source_index (QtCore.QModelIndex): index from source to retrieve + data from to group by. + + Returns: + object: Collected data to group by for index. + + """ + return source_index.data(self.group_role) + + def groupby_label(self, section): + """Returns the label for a section based on the collected group key. + + Override this in subclasses to format the name for a specific key. + + Args: + section: key value for this group section + + Returns: + str: Label of the section header based on group key + """ + return section + + def rebuild(self): + """Update proxy sections and items + + This should be called after changes in the source model that require + changes in this list (for example new indices, less indices or update + sections) + + """ + + self.reset() + + # Start with new root node + self.root = Item() + + # Get indices from source model + source = self.sourceModel() + source_rows = source.rowCount() + source_indices = [source.index(i, 0) for i in range(source_rows)] + + for section, group in groupby(source_indices, + key=self.groupby_key): + + # section + label = self.groupby_label(section) + section_item = ProxySectionItem(label) + self.root.addChild(section_item) + + # items in section + for i, index in enumerate(group): + proxy_item = ProxyItem(index) + section_item.addChild(proxy_item) + + def data(self, index, role=QtCore.Qt.DisplayRole): + + if not index.isValid(): + return + + node = index.internalPointer() + + if not node: + return + + return node.data(role) + + def setData(self, index, data, role): + + source_idx = self.mapToSource(index) + if not source_idx.isValid(): + return + + model = source_idx.model() + model.setData(index, data, role) + + if __binding__ in ("PyQt4", "PySide"): + self.dataChanged.emit(index, index) + else: + self.dataChanged.emit(index, index, [role]) + + def is_header(self, index): + """Return whether index is a header""" + + if index.isValid() and not self.mapToSource(index).isValid(): + return True + else: + return False + + def mapFromSource(self, index): + + for section_item in self.root.children(): + for item in section_item.children(): + if item.source_index == index: + return self.createIndex(item.row(), + index.column(), + item) + + return QtCore.QModelIndex() + + def mapToSource(self, index): + + if not index.isValid(): + return QtCore.QModelIndex() + + node = index.internalPointer() + if not node: + return QtCore.QModelIndex() + + if not hasattr(node, "source_index"): + return QtCore.QModelIndex() + + return node.source_index + + def columnCount(self, parent=QtCore.QModelIndex()): + return 1 + + def rowCount(self, parent): + + if not parent.isValid(): + node = self.root + else: + node = parent.internalPointer() + + if not node: + return 0 + + return node.rowCount() + + def index(self, row, column, parent): + + if parent and parent.isValid(): + parent_node = parent.internalPointer() + else: + parent_node = self.root + + item = parent_node.child(row) + if item: + return self.createIndex(row, column, item) + else: + return QtCore.QModelIndex() + + def parent(self, index): + + if not index.isValid(): + return QtCore.QModelIndex() + + node = index.internalPointer() + if not node: + return QtCore.QModelIndex() + else: + parent = node.parent() + if not parent: + return QtCore.QModelIndex() + + row = parent.row() + return self.createIndex(row, 0, parent) + + +class PluginOrderGroupProxy(Proxy): + """Proxy grouping by order by full range known. + + Before Collectors and after Integrators will be grouped as "Other". + + """ + + def groupby_key(self, source_index): + plugin_order = super(PluginOrderGroupProxy, + self).groupby_key(source_index) + label = "Other" + + mapping = {pyblish.plugin.CollectorOrder: "Collector", + pyblish.plugin.ValidatorOrder: "Validator", + pyblish.plugin.ExtractorOrder: "Extractor", + pyblish.plugin.IntegratorOrder: "Integrator"} + for order, _type in mapping.items(): + if pyblish.lib.inrange(plugin_order, base=order): + label = _type + + return label + + +class FamilyGroupProxy(Proxy): + """Proxy grouping by order by full range known. + + Before Collectors and after Integrators will be grouped as "Other". + + """ + + def groupby_key(self, source_index): + families = super(FamilyGroupProxy, + self).groupby_key(source_index) + family = families[0] + return family + + +class View(QtWidgets.QTreeView): + # An item is requesting to be toggled, with optional forced-state + toggled = QtCore.Signal("QModelIndex", object) + + # An item is requesting details + inspected = QtCore.Signal("QModelIndex") + + def __init__(self, parent=None): + super(View, self).__init__(parent) + + self.horizontalScrollBar().hide() + self.viewport().setAttribute(QtCore.Qt.WA_Hover, True) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.setVerticalScrollMode(QtWidgets.QTreeView.ScrollPerPixel) + self.setHeaderHidden(True) + self.setRootIsDecorated(False) + self.setIndentation(0) + + def event(self, event): + if not event.type() == QtCore.QEvent.KeyPress: + return super(View, self).event(event) + + elif event.key() == QtCore.Qt.Key_Space: + for index in self.selectionModel().selectedIndexes(): + self.toggled.emit(index, None) + + return True + + elif event.key() == QtCore.Qt.Key_Backspace: + for index in self.selectionModel().selectedIndexes(): + self.toggled.emit(index, False) + + return True + + elif event.key() == QtCore.Qt.Key_Return: + for index in self.selectionModel().selectedIndexes(): + self.toggled.emit(index, True) + + return True + + return super(View, self).event(event) + + def focusOutEvent(self, event): + self.selectionModel().clear() + + def leaveEvent(self, event): + self._inspecting = False + super(View, self).leaveEvent(event) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.MidButton: + index = self.indexAt(event.pos()) + self.inspected.emit(index) if index.isValid() else None + + return super(View, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) <= 1 and event.pos().x() < 20: + for index in indexes: + self.toggled.emit(index, None) + + return super(View, self).mouseReleaseEvent(event) diff --git a/pyblish_lite/window.py b/pyblish_lite/window.py index fc0bf1b..f1ef027 100644 --- a/pyblish_lite/window.py +++ b/pyblish_lite/window.py @@ -42,7 +42,7 @@ from Qt import QtCore, QtWidgets, QtGui -from . import model, view, util, delegate +from . import model, view, util, delegate, tree from .awesome import tags as awesome @@ -133,10 +133,10 @@ def __init__(self, controller, parent=None): overview_page = QtWidgets.QWidget() - left_view = view.Item() - right_view = view.Item() + left_view = tree.View() + right_view = tree.View() - item_delegate = delegate.Item() + item_delegate = delegate.ItemAndSection() left_view.setItemDelegate(item_delegate) right_view.setItemDelegate(item_delegate) @@ -357,8 +357,16 @@ def __init__(self, controller, parent=None): terminal_model = model.Terminal() artist_view.setModel(instance_model) - left_view.setModel(instance_model) - right_view.setModel(plugin_model) + + left_proxy = tree.FamilyGroupProxy() + left_proxy.setSourceModel(instance_model) + left_proxy.set_group_role(model.Families) + left_view.setModel(left_proxy) + + right_proxy = tree.PluginOrderGroupProxy() + right_proxy.setSourceModel(plugin_model) + right_proxy.set_group_role(model.Order) + right_view.setModel(right_proxy) terminal_view.setModel(terminal_model) instance_combo.setModel(instance_model) @@ -490,6 +498,30 @@ def __init__(self, controller, parent=None): controller.was_acted.connect(self.on_was_acted) controller.finished.connect(self.on_finished) + controller.was_reset.connect(left_proxy.rebuild) + controller.was_validated.connect(left_proxy.rebuild) + controller.was_published.connect(left_proxy.rebuild) + controller.was_acted.connect(left_proxy.rebuild) + controller.finished.connect(left_proxy.rebuild) + + controller.was_reset.connect(left_view.expandAll) + controller.was_validated.connect(left_view.expandAll) + controller.was_published.connect(left_view.expandAll) + controller.was_acted.connect(left_view.expandAll) + controller.finished.connect(left_view.expandAll) + + controller.was_reset.connect(right_proxy.rebuild) + controller.was_validated.connect(right_proxy.rebuild) + controller.was_published.connect(right_proxy.rebuild) + controller.was_acted.connect(right_proxy.rebuild) + controller.finished.connect(right_proxy.rebuild) + + controller.was_reset.connect(right_view.expandAll) + controller.was_validated.connect(right_view.expandAll) + controller.was_published.connect(right_view.expandAll) + controller.was_acted.connect(right_view.expandAll) + controller.finished.connect(right_view.expandAll) + # Discovery happens synchronously during reset, that's # why it's important that this connection is triggered # right away.