diff --git a/avalon/inventory.py b/avalon/inventory.py index a735cc0a6..54bc07572 100644 --- a/avalon/inventory.py +++ b/avalon/inventory.py @@ -326,6 +326,7 @@ def _save_config_1_0(project_name, data): config["tasks"] = data.get("tasks", []) config["template"].update(data.get("template", {})) config["families"] = data.get("families", []) + config["groups"] = data.get("groups", []) schema.validate(document) diff --git a/avalon/schema/config-1.0.json b/avalon/schema/config-1.0.json index 97392e9dd..5645e5f91 100644 --- a/avalon/schema/config-1.0.json +++ b/avalon/schema/config-1.0.json @@ -65,6 +65,19 @@ "required": ["name"] } }, + "groups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "icon": {"type": "string"}, + "color": {"type": "string"}, + "order": {"type": ["integer", "number"]} + }, + "required": ["name"] + } + }, "copy": { "type": "object" } diff --git a/avalon/tests/test_inventory.py b/avalon/tests/test_inventory.py index 8c653d14e..bc9471d4d 100644 --- a/avalon/tests/test_inventory.py +++ b/avalon/tests/test_inventory.py @@ -137,6 +137,14 @@ def test_save(): "families": [ {"name": "avalon.model", "label": "Model", "icon": "cube"} ], + "groups": [ + { + "name": "charCaches", + "icon": "diamond", + "color": "#C4CEDC", + "order": -99 + }, + ], "copy": {} } diff --git a/avalon/tools/cbloader/app.py b/avalon/tools/cbloader/app.py index e46456236..a8e0a79e4 100644 --- a/avalon/tools/cbloader/app.py +++ b/avalon/tools/cbloader/app.py @@ -1,13 +1,21 @@ import sys import time -from ..projectmanager.widget import AssetWidget, AssetModel +from ..projectmanager.widget import ( + AssetWidget, + AssetModel, + preserve_selection, +) from ...vendor.Qt import QtWidgets, QtCore from ... import api, io, style from .. import lib -from .lib import refresh_family_config +from .lib import ( + refresh_family_config, + refresh_group_config, + get_active_group_config, +) from .widgets import SubsetWidget, VersionWidget, FamilyListWidget module = sys.modules[__name__] @@ -91,6 +99,7 @@ def __init__(self, parent=None): "root": None, "project": None, "asset": None, + "assetId": None, "silo": None, "subset": None, "version": None, @@ -105,6 +114,7 @@ def __init__(self, parent=None): subsets.version_changed.connect(self.on_versionschanged) refresh_family_config() + refresh_group_config() # Defaults self.resize(1330, 700) @@ -173,15 +183,11 @@ def _assetschanged(self): document = asset_item.data(DocumentRole) subsets_model.set_asset(document['_id']) - # Enforce the columns to fit the data (purely cosmetic) - rows = subsets_model.rowCount(QtCore.QModelIndex()) - for i in range(rows): - subsets.view.resizeColumnToContents(i) - # Clear the version information on asset change self.data['model']['version'].set_version(None) self.data["state"]["context"]["asset"] = document["name"] + self.data["state"]["context"]["assetId"] = document["_id"] self.data["state"]["context"]["silo"] = document["silo"] self.echo("Duration: %.3fs" % (time.time() - t1)) @@ -198,7 +204,8 @@ def _versionschanged(self): rows = selection.selectedRows(column=active.column()) if active in rows: node = active.data(subsets.model.NodeRole) - version = node['version_document']['_id'] + if node is not None and not node.get("isGroup"): + version = node['version_document']['_id'] self.data['model']['version'].set_version(version) @@ -269,6 +276,114 @@ def closeEvent(self, event): print("Good bye") return super(Window, self).closeEvent(event) + def keyPressEvent(self, event): + modifiers = event.modifiers() + ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + + # Grouping subsets on pressing Ctrl + G + if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and + not event.isAutoRepeat()): + self.show_grouping_dialog() + return + + return super(Window, self).keyPressEvent(event) + + def show_grouping_dialog(self): + subsets = self.data["model"]["subsets"] + if not subsets.is_groupable(): + self.echo("Grouping not enabled.") + return + + selected = subsets.selected_subsets() + if not selected: + self.echo("No selected subset.") + return + + dialog = SubsetGroupingDialog(items=selected, parent=self) + dialog.grouped.connect(self._assetschanged) + dialog.show() + + +class SubsetGroupingDialog(QtWidgets.QDialog): + + grouped = QtCore.Signal() + + def __init__(self, items, parent=None): + super(SubsetGroupingDialog, self).__init__(parent=parent) + self.setWindowTitle("Grouping Subsets") + self.setMinimumWidth(250) + self.setModal(True) + + self.items = items + self.subsets = parent.data["model"]["subsets"] + self.asset_id = parent.data["state"]["context"]["assetId"] + + name = QtWidgets.QLineEdit() + name.setPlaceholderText("Remain blank to ungroup..") + + # Menu for pre-defined subset groups + name_button = QtWidgets.QPushButton() + name_button.setFixedWidth(18) + name_button.setFixedHeight(20) + name_menu = QtWidgets.QMenu(name_button) + name_button.setMenu(name_menu) + + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(name) + name_layout.addWidget(name_button) + name_layout.setContentsMargins(0, 0, 0, 0) + + group_btn = QtWidgets.QPushButton("Apply") + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel("Group Name")) + layout.addLayout(name_layout) + layout.addWidget(group_btn) + + group_btn.clicked.connect(self.on_group) + group_btn.setAutoDefault(True) + group_btn.setDefault(True) + + self.name = name + self.name_menu = name_menu + + self._build_menu() + + def _build_menu(self): + menu = self.name_menu + button = menu.parent() + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + active_groups = get_active_group_config(self.asset_id, + include_predefined=True) + # Build new action group + group = QtWidgets.QActionGroup(button) + for data in sorted(active_groups, key=lambda x: x["order"]): + name = data["name"] + icon = data["icon"] + + action = group.addAction(name) + action.setIcon(icon) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + button.setEnabled(not menu.isEmpty()) + + def _on_action_clicked(self, action): + self.name.setText(action.text()) + + def on_group(self): + name = self.name.text().strip() + self.subsets.group_subsets(name, self.asset_id, self.items) + + with preserve_selection(tree_view=self.subsets.view, + current_index=False): + self.grouped.emit() + self.close() + def show(debug=False, parent=None, use_context=False): """Display Loader GUI diff --git a/avalon/tools/cbloader/delegates.py b/avalon/tools/cbloader/delegates.py index 59d7fbbba..7b9772e45 100644 --- a/avalon/tools/cbloader/delegates.py +++ b/avalon/tools/cbloader/delegates.py @@ -120,6 +120,10 @@ def displayText(self, value, locale): return self._format_version(value) def createEditor(self, parent, option, index): + node = index.data(SubsetsModel.NodeRole) + if node.get("isGroup"): + return + editor = QtWidgets.QComboBox(parent) def commit_data(): diff --git a/avalon/tools/cbloader/lib.py b/avalon/tools/cbloader/lib.py index 897ac8931..caa5edf5a 100644 --- a/avalon/tools/cbloader/lib.py +++ b/avalon/tools/cbloader/lib.py @@ -1,8 +1,9 @@ -from ...vendor import qtawesome -from ... import io, api +from ...vendor import qtawesome, Qt +from ... import io, api, style FAMILY_ICON_COLOR = "#0091B2" FAMILY_CONFIG = {} +GROUP_CONFIG = {} def get(config, name): @@ -11,6 +12,16 @@ def get(config, name): return config.get(name, config.get("__default__", None)) +def is_filtering_recursible(): + """Does Qt binding support recursive filtering for QSortFilterProxyModel ? + + (NOTE) Recursive filtering was introduced in Qt 5.10. + + """ + return hasattr(Qt.QtCore.QSortFilterProxyModel, + "setRecursiveFilteringEnabled") + + def refresh_family_config(): """Get the family configurations from the database @@ -71,3 +82,101 @@ def refresh_family_config(): FAMILY_CONFIG.update(families) return families + + +def refresh_group_config(): + """Get subset group configurations from the database + + The 'group' configuration must be stored in the project `config` field. + See schema `config-1.0.json` + + """ + + # Subset group item's default icon and order + default_group_icon = qtawesome.icon("fa.object-group", + color=style.colors.default) + default_group_config = {"icon": default_group_icon, + "order": 0} + + # Get pre-defined group name and apperance from project config + project = io.find_one({"type": "project"}, + projection={"config.groups": True}) + + assert project, "Project not found!" + group_configs = project["config"].get("groups", []) + + # Build pre-defined group configs + groups = dict() + for config in group_configs: + name = config["name"] + icon = "fa." + config.get("icon", "object-group") + color = config.get("color", style.colors.default) + order = float(config.get("order", 0)) + + groups[name] = {"icon": qtawesome.icon(icon, color=color), + "order": order} + + # Default configuration + groups["__default__"] = default_group_config + + GROUP_CONFIG.clear() + GROUP_CONFIG.update(groups) + + return groups + + +def get_active_group_config(asset_id, include_predefined=False): + """Collect all active groups from each subset + """ + predefineds = GROUP_CONFIG.copy() + default_group_config = predefineds.pop("__default__") + + _orders = set([0]) # default order zero included + for config in predefineds.values(): + _orders.add(config["order"]) + + # Remap order to list index + orders = sorted(_orders) + + # Collect groups from subsets + group_names = set(io.distinct("data.subsetGroup", + {"type": "subset", "parent": asset_id})) + if include_predefined: + # Ensure all predefined group configs will be included + group_names.update(predefineds.keys()) + + groups = list() + + for name in group_names: + # Get group config + config = predefineds.get(name, default_group_config) + # Base order + remapped_order = orders.index(config["order"]) + + data = { + "name": name, + "icon": config["icon"], + "_order": remapped_order, + } + + groups.append(data) + + # Sort by tuple (base_order, name) + # If there are multiple groups in same order, will sorted by name. + ordered = sorted(groups, key=lambda dat: (dat.pop("_order"), dat["name"])) + + total = len(ordered) + order_temp = "%0{}d".format(len(str(total))) + + # Update sorted order to config + for index, data in enumerate(ordered): + order = index + inverse_order = total - order + + data.update({ + # Format orders into fixed length string for groups sorting + "order": order_temp % order, + "inverseOrder": order_temp % inverse_order, + }) + + return ordered diff --git a/avalon/tools/cbloader/model.py b/avalon/tools/cbloader/model.py index d559365ec..1757d1ba7 100644 --- a/avalon/tools/cbloader/model.py +++ b/avalon/tools/cbloader/model.py @@ -21,9 +21,14 @@ class SubsetsModel(TreeModel): "handles", "step"] - def __init__(self, parent=None): + SortAscendingRole = QtCore.Qt.UserRole + 2 + SortDescendingRole = QtCore.Qt.UserRole + 3 + + def __init__(self, grouping=True, parent=None): super(SubsetsModel, self).__init__(parent=parent) self._asset_id = None + self._sorter = None + self._grouping = grouping self._icons = {"subset": qta.icon("fa.file-o", color=style.colors.default)} @@ -31,6 +36,10 @@ def set_asset(self, asset_id): self._asset_id = asset_id self.refresh() + def set_grouping(self, state): + self._grouping = state + self.refresh() + def setData(self, index, value, role=QtCore.Qt.EditRole): # Trigger additional edit when `version` column changed @@ -104,31 +113,61 @@ def refresh(self): self.endResetModel() return - row = 0 - for subset in io.find({"type": "subset", - "parent": self._asset_id}): + asset_id = self._asset_id + + active_groups = lib.get_active_group_config(asset_id) + + # Generate subset group nodes + group_nodes = dict() + + if self._grouping: + for data in active_groups: + name = data.pop("name") + group = Node() + group.update({"subset": name, "isGroup": True, "childRow": 0}) + group.update(data) + + group_nodes[name] = group + self.add_child(group) + + filter = {"type": "subset", "parent": asset_id} + + # Process subsets + row = len(group_nodes) + for subset in io.find(filter): last_version = io.find_one({"type": "version", - "parent": subset['_id']}, + "parent": subset["_id"]}, sort=[("name", -1)]) if not last_version: # No published version for the subset continue data = subset.copy() - data['subset'] = data['name'] + data["subset"] = data["name"] + + group_name = subset["data"].get("subsetGroup") + if self._grouping and group_name: + group = group_nodes[group_name] + parent = group + parent_index = self.createIndex(0, 0, group) + row_ = group["childRow"] + group["childRow"] += 1 + else: + parent = None + parent_index = QtCore.QModelIndex() + row_ = row + row += 1 node = Node() node.update(data) - self.add_child(node) + self.add_child(node, parent=parent) # Set the version information - index = self.index(row, 0, parent=QtCore.QModelIndex()) + index = self.index(row_, 0, parent=parent_index) self.set_version(index, last_version) - row += 1 - self.endResetModel() def data(self, index, role): @@ -146,13 +185,41 @@ def data(self, index, role): # Add icon to subset column if index.column() == 0: - return self._icons['subset'] + node = index.internalPointer() + if node.get("isGroup"): + return node["icon"] + else: + return self._icons["subset"] # Add icon to family column if index.column() == 1: node = index.internalPointer() return node.get("familyIcon", None) + if role == self.SortDescendingRole: + node = index.internalPointer() + if node.get("isGroup"): + # Ensure groups be on top when sorting by descending order + prefix = "1" + order = node["inverseOrder"] + else: + prefix = "0" + order = str(super(SubsetsModel, + self).data(index, QtCore.Qt.DisplayRole)) + return prefix + order + + if role == self.SortAscendingRole: + node = index.internalPointer() + if node.get("isGroup"): + # Ensure groups be on top when sorting by ascending order + prefix = "0" + order = node["order"] + else: + prefix = "1" + order = str(super(SubsetsModel, + self).data(index, QtCore.Qt.DisplayRole)) + return prefix + order + return super(SubsetsModel, self).data(index, role) def flags(self, index): @@ -165,7 +232,55 @@ def flags(self, index): return flags -class FamiliesFilterProxyModel(QtCore.QSortFilterProxyModel): +class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel): + """Provide the feature of filtering group by the acceptance of members + + The subset group nodes will not be filtered directly, the group node's + acceptance depends on it's child subsets' acceptance. + + """ + + if lib.is_filtering_recursible(): + def _is_group_acceptable(self, index, node): + # (NOTE) With the help of `RecursiveFiltering` feature from + # Qt 5.10, group always not be accepted by default. + return False + filter_accepts_group = _is_group_acceptable + + else: + # Patch future function + setRecursiveFilteringEnabled = (lambda *args: None) + + def _is_group_acceptable(self, index, model): + # (NOTE) This is not recursive. + for child_row in range(model.rowCount(index)): + if self.filterAcceptsRow(child_row, index): + return True + return False + filter_accepts_group = _is_group_acceptable + + def __init__(self, *args, **kwargs): + super(GroupMemberFilterProxyModel, self).__init__(*args, **kwargs) + self.setRecursiveFilteringEnabled(True) + + +class SubsetFilterProxyModel(GroupMemberFilterProxyModel): + + def filterAcceptsRow(self, row, parent): + + model = self.sourceModel() + index = model.index(row, + self.filterKeyColumn(), + parent) + node = index.internalPointer() + if node.get("isGroup"): + return self.filter_accepts_group(index, model) + else: + return super(SubsetFilterProxyModel, + self).filterAcceptsRow(row, parent) + + +class FamiliesFilterProxyModel(GroupMemberFilterProxyModel): """Filters to specified families""" def __init__(self, *args, **kwargs): @@ -195,6 +310,10 @@ def filterAcceptsRow(self, row=0, parent=QtCore.QModelIndex()): # Get the node data and validate node = model.data(index, TreeModel.NodeRole) + + if node.get("isGroup"): + return self.filter_accepts_group(index, model) + family = node.get("family", None) if not family: @@ -202,3 +321,14 @@ def filterAcceptsRow(self, row=0, parent=QtCore.QModelIndex()): # We want to keep the families which are not in the list return family in self._families + + def sort(self, column, order): + proxy = self.sourceModel() + model = proxy.sourceModel() + # We need to know the sorting direction for pinning groups on top + if order == QtCore.Qt.AscendingOrder: + self.setSortRole(model.SortAscendingRole) + else: + self.setSortRole(model.SortDescendingRole) + + super(FamiliesFilterProxyModel, self).sort(column, order) diff --git a/avalon/tools/cbloader/widgets.py b/avalon/tools/cbloader/widgets.py index 35f0233e0..bfcc609ee 100644 --- a/avalon/tools/cbloader/widgets.py +++ b/avalon/tools/cbloader/widgets.py @@ -8,7 +8,13 @@ from ... import api from ... import pipeline -from .model import SubsetsModel, FamiliesFilterProxyModel +from ..projectmanager.widget import preserve_selection + +from .model import ( + SubsetsModel, + SubsetFilterProxyModel, + FamiliesFilterProxyModel, +) from .delegates import PrettyTimeDelegate, VersionDelegate from . import lib @@ -19,19 +25,26 @@ class SubsetWidget(QtWidgets.QWidget): active_changed = QtCore.Signal() # active index changed version_changed = QtCore.Signal() # version state changed for a subset - def __init__(self, parent=None): + def __init__(self, enable_grouping=True, parent=None): super(SubsetWidget, self).__init__(parent=parent) - model = SubsetsModel() - proxy = QtCore.QSortFilterProxyModel() + model = SubsetsModel(grouping=enable_grouping) + proxy = SubsetFilterProxyModel() family_proxy = FamiliesFilterProxyModel() family_proxy.setSourceModel(proxy) filter = QtWidgets.QLineEdit() filter.setPlaceholderText("Filter subsets..") + groupable = QtWidgets.QCheckBox("Enable Grouping") + groupable.setChecked(enable_grouping) + + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addWidget(filter) + top_bar_layout.addWidget(groupable) + view = QtWidgets.QTreeView() - view.setIndentation(5) + view.setIndentation(20) view.setStyleSheet(""" QTreeView::item{ padding: 5px 1px; @@ -51,7 +64,7 @@ def __init__(self, parent=None): layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(filter) + layout.addLayout(top_bar_layout) layout.addWidget(view) view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) @@ -64,6 +77,9 @@ def __init__(self, parent=None): "delegates": { "version": version_delegate, "time": time_delegate + }, + "state": { + "groupable": groupable } } @@ -81,29 +97,48 @@ def __init__(self, parent=None): self.view.setModel(self.family_proxy) self.view.customContextMenuRequested.connect(self.on_context_menu) + header = self.view.header() + # Enforce the columns to fit the data (purely cosmetic) + header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) + selection = view.selectionModel() selection.selectionChanged.connect(self.active_changed) version_delegate.version_changed.connect(self.version_changed) + groupable.stateChanged.connect(self.set_grouping) + self.filter.textChanged.connect(self.proxy.setFilterRegExp) + self.filter.textChanged.connect(self.view.expandAll) self.model.refresh() # Expose this from the widget as a method self.set_family_filters = self.family_proxy.setFamiliesFilter + def is_groupable(self): + return self.data["state"]["groupable"].checkState() + + def set_grouping(self, state): + with preserve_selection(tree_view=self.view, + current_index=False): + self.model.set_grouping(state) + def on_context_menu(self, point): point_index = self.view.indexAt(point) if not point_index.isValid(): return + node = point_index.data(self.model.NodeRole) + if node.get("isGroup"): + return + # Get all representation->loader combinations available for the # index under the cursor, so we can list the user the options. available_loaders = api.discover(api.Loader) loaders = list() - node = point_index.data(self.model.NodeRole) + version_id = node['version_document']['_id'] representations = io.find({"type": "representation", "parent": version_id}) @@ -188,6 +223,9 @@ def sorter(value): # Trigger for row in rows: node = row.data(self.model.NodeRole) + if node.get("isGroup"): + continue + version_id = node["version_document"]["_id"] representation = io.find_one({"type": "representation", "name": representation_name, @@ -205,6 +243,39 @@ def sorter(value): self.echo(exc) continue + def selected_subsets(self): + selection = self.view.selectionModel() + rows = selection.selectedRows(column=0) + + subsets = list() + for row in rows: + node = row.data(self.model.NodeRole) + if not node.get("isGroup"): + subsets.append(node) + + return subsets + + def group_subsets(self, name, asset_id, nodes): + field = "data.subsetGroup" + + if name: + update = {"$set": {field: name}} + self.echo("Group subsets to '%s'.." % name) + else: + update = {"$unset": {field: ""}} + self.echo("Ungroup subsets..") + + subsets = list() + for node in nodes: + subsets.append(node["subset"]) + + filter = { + "type": "subset", + "parent": asset_id, + "name": {"$in": subsets}, + } + io.update_many(filter, update) + def echo(self, message): print(message)