diff --git a/avalon/tools/contextmanager/app.py b/avalon/tools/contextmanager/app.py index 141713b0c..c0512f9d9 100644 --- a/avalon/tools/contextmanager/app.py +++ b/avalon/tools/contextmanager/app.py @@ -1,12 +1,11 @@ import sys import logging -import avalon.api as api - -from avalon.vendor.Qt import QtWidgets, QtCore -from avalon.tools.projectmanager.widget import AssetWidget -from avalon.tools.projectmanager.app import TasksModel +from ... import api +from ...vendor.Qt import QtWidgets, QtCore +from ..widgets import AssetWidget +from ..models import TasksModel module = sys.modules[__name__] module.window = None @@ -109,7 +108,7 @@ def _get_selected_task_name(self): def _get_selected_asset_name(self): asset_index = self._assets.get_active_index() - asset_data = asset_index.data(self._assets.model.NodeRole) + asset_data = asset_index.data(self._assets.model.ItemRole) if not asset_data or not isinstance(asset_data, dict): return diff --git a/avalon/tools/creator/app.py b/avalon/tools/creator/app.py index 34aff8b83..0afd5ce88 100644 --- a/avalon/tools/creator/app.py +++ b/avalon/tools/creator/app.py @@ -1,4 +1,3 @@ - import sys import inspect @@ -6,6 +5,7 @@ from ...vendor import qtawesome from ...vendor import six from ... import api, io, style + from .. import lib module = sys.modules[__name__] @@ -139,8 +139,8 @@ def __init__(self, parent=None): create_btn.setEnabled(False) def _on_state_changed(self, state): - self.state['valid'] = state - self.data['Create Button'].setEnabled(state) + self.state["valid"] = state + self.data["Create Button"].setEnabled(state) def _build_menu(self, default_names): """Create optional predefined subset names @@ -311,7 +311,7 @@ def refresh(self): def on_create(self): # Do not allow creation in an invalid state - if not self.state['valid']: + if not self.state["valid"]: return asset = self.data["Asset"] @@ -415,7 +415,7 @@ def set_item(self, item): """Update elements to display information of a family item. Args: - family (dict): A family item as registered with name, help and icon + item (dict): A family item as registered with name, help and icon Returns: None @@ -445,11 +445,10 @@ def show(debug=False, parent=None): """Display asset creator GUI Arguments: - creator (func, optional): Callable function, passed `name`, - `family` and `use_selection`, defaults to `creator` - defined in :mod:`pipeline` debug (bool, optional): Run loader in debug-mode, defaults to False + parent (QtCore.QObject, optional): When provided parent the interface + to this QObject. """ diff --git a/avalon/tools/delegates.py b/avalon/tools/delegates.py new file mode 100644 index 000000000..33dd9a8c1 --- /dev/null +++ b/avalon/tools/delegates.py @@ -0,0 +1,71 @@ +from ..vendor.Qt import QtWidgets, QtCore +from .. import io + +from .models import TreeModel + + +class VersionDelegate(QtWidgets.QStyledItemDelegate): + """A delegate that display version integer formatted as version string.""" + + version_changed = QtCore.Signal() + first_run = False + lock = False + + def _format_version(self, value): + """Formats integer to displayable version name""" + return "v{0:03d}".format(value) + + def displayText(self, value, locale): + assert isinstance(value, int), "Version is not `int`" + return self._format_version(value) + + def createEditor(self, parent, option, index): + item = index.data(TreeModel.ItemRole) + if item.get("isGroup"): + return + + editor = QtWidgets.QComboBox(parent) + + def commit_data(): + if not self.first_run: + self.commitData.emit(editor) # Update model data + self.version_changed.emit() # Display model data + editor.currentIndexChanged.connect(commit_data) + + self.first_run = True + self.lock = False + + return editor + + def setEditorData(self, editor, index): + if self.lock: + # Only set editor data once per delegation + return + + editor.clear() + + # Current value of the index + value = index.data(QtCore.Qt.DisplayRole) + assert isinstance(value, int), "Version is not `int`" + + # Add all available versions to the editor + item = index.data(TreeModel.ItemRole) + parent_id = item["version_document"]["parent"] + versions = io.find({"type": "version", "parent": parent_id}, + sort=[("name", 1)]) + index = 0 + for i, version in enumerate(versions): + label = self._format_version(version["name"]) + editor.addItem(label, userData=version) + + if version["name"] == value: + index = i + + editor.setCurrentIndex(index) # Will trigger index-change signal + self.first_run = False + self.lock = True + + def setModelData(self, editor, model, index): + """Apply the integer version back in the model""" + version = editor.itemData(editor.currentIndex()) + model.setData(index, version["name"]) diff --git a/avalon/tools/lib.py b/avalon/tools/lib.py index e9ff0cb34..6d0e4d078 100644 --- a/avalon/tools/lib.py +++ b/avalon/tools/lib.py @@ -2,6 +2,9 @@ import sys import contextlib +from .. import io, api, style +from ..vendor import qtawesome + from ..vendor.Qt import QtWidgets, QtCore, QtGui self = sys.modules[__name__] @@ -82,3 +85,300 @@ def dummy(): """ yield + + +def iter_model_rows(model, + column, + include_root=False): + """Iterate over all row indices in a model""" + indices = [QtCore.QModelIndex()] # start iteration at root + + for index in indices: + + # Add children to the iterations + child_rows = model.rowCount(index) + for child_row in range(child_rows): + child_index = model.index(child_row, column, index) + indices.append(child_index) + + if not include_root and not index.isValid(): + continue + + yield index + + +@contextlib.contextmanager +def preserve_expanded_rows(tree_view, + column=0, + role=QtCore.Qt.DisplayRole): + """Preserves expanded row in QTreeView by column's data role. + + This function is created to maintain the expand vs collapse status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + Arguments: + tree_view (QWidgets.QTreeView): the tree view which is + nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + + model = tree_view.model() + + expanded = set() + + for index in iter_model_rows(model, + column=column, + include_root=False): + if tree_view.isExpanded(index): + value = index.data(role) + expanded.add(value) + + try: + yield + finally: + if not expanded: + return + + for index in iter_model_rows(model, + column=column, + include_root=False): + value = index.data(role) + state = value in expanded + if state: + tree_view.expand(index) + else: + tree_view.collapse(index) + + +@contextlib.contextmanager +def preserve_selection(tree_view, + column=0, + role=QtCore.Qt.DisplayRole, + current_index=True): + """Preserves row selection in QTreeView by column's data role. + + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + if current_index: + current_index_value = tree_view.currentIndex().data(role) + else: + current_index_value = None + + selected_rows = selection_model.selectedRows() + if not selected_rows: + yield + return + + selected = set(row.data(role) for row in selected_rows) + try: + yield + finally: + if not selected: + return + + # Go through all indices, select the ones with similar data + for index in iter_model_rows(model, + column=column, + include_root=False): + + value = index.data(role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + if current_index_value and value == current_index_value: + selection_model.setCurrentIndex(index, + selection_model.NoUpdate) + + +FAMILY_ICON_COLOR = "#0091B2" +FAMILY_CONFIG_CACHE = {} +GROUP_CONFIG_CACHE = {} + + +def get_family_cached_config(name): + """Get value from config with fallback to default""" + # We assume the default fallback key in the config is `__default__` + config = FAMILY_CONFIG_CACHE + return config.get(name, config.get("__default__", None)) + + +def refresh_family_config_cache(): + """Get the family configurations from the database + + The configuration must be stored on the project under `config`. + For example: + + { + "config": { + "families": [ + {"name": "avalon.camera", label: "Camera", "icon": "photo"}, + {"name": "avalon.anim", label: "Animation", "icon": "male"}, + ] + } + } + + It is possible to override the default behavior and set specific families + checked. For example we only want the families imagesequence and camera + to be visible in the Loader. + + # This will turn every item off + api.data["familyStateDefault"] = False + + # Only allow the imagesequence and camera + api.data["familyStateToggled"] = ["imagesequence", "camera"] + + """ + # Update the icons from the project configuration + project = io.find_one({"type": "project"}, + projection={"config.families": True}) + + assert project, "Project not found!" + families = project["config"].get("families", []) + families = {family["name"]: family for family in families} + + # Check if any family state are being overwritten by the configuration + default_state = api.data.get("familiesStateDefault", True) + toggled = set(api.data.get("familiesStateToggled", [])) + + # Replace icons with a Qt icon we can use in the user interfaces + default_icon = qtawesome.icon("fa.folder", color=FAMILY_ICON_COLOR) + for name, family in families.items(): + # Set family icon + icon = family.get("icon", None) + if icon: + family["icon"] = qtawesome.icon("fa.{}".format(icon), + color=FAMILY_ICON_COLOR) + else: + family["icon"] = default_icon + + # Update state + state = not default_state if name in toggled else default_state + family["state"] = state + + # Default configuration + families["__default__"] = {"icon": default_icon} + + FAMILY_CONFIG_CACHE.clear() + FAMILY_CONFIG_CACHE.update(families) + + return families + + +def refresh_group_config_cache(): + """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_CACHE.clear() + GROUP_CONFIG_CACHE.update(groups) + + return groups + + +def get_active_group_config(asset_id, include_predefined=False): + """Collect all active groups from each subset""" + predefineds = GROUP_CONFIG_CACHE.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/loader/app.py b/avalon/tools/loader/app.py index 30210aedf..6c439c586 100644 --- a/avalon/tools/loader/app.py +++ b/avalon/tools/loader/app.py @@ -1,21 +1,13 @@ import sys import time -from ..projectmanager.widget import ( - AssetWidget, - AssetModel, - preserve_selection, -) - from ...vendor.Qt import QtWidgets, QtCore from ... import api, io, style + +from ..models import AssetModel +from ..widgets import AssetWidget from .. import lib -from .lib import ( - refresh_family_config, - refresh_group_config, - get_active_group_config, -) from .widgets import SubsetWidget, VersionWidget, FamilyListWidget module = sys.modules[__name__] @@ -182,10 +174,10 @@ def _assetschanged(self): return document = asset_item.data(DocumentRole) - subsets_model.set_asset(document['_id']) + subsets_model.set_asset(document["_id"]) # Clear the version information on asset change - self.data['model']['version'].set_version(None) + self.data["model"]["version"].set_version(None) self.data["state"]["context"]["asset"] = document["name"] self.data["state"]["context"]["assetId"] = document["_id"] @@ -204,11 +196,11 @@ def _versionschanged(self): if active: rows = selection.selectedRows(column=active.column()) if active in rows: - node = active.data(subsets.model.NodeRole) + node = active.data(subsets.model.ItemRole) if node is not None and not node.get("isGroup"): - version = node['version_document']['_id'] + version = node["version_document"]["_id"] - self.data['model']['version'].set_version(version) + self.data["model"]["version"].set_version(version) def _set_context(self, context, refresh=True): """Set the selection in the interface using a context. @@ -245,7 +237,7 @@ def _set_context(self, context, refresh=True): # scheduled refresh and the silo tabs are not shown. self._refresh() - asset_widget = self.data['model']['assets'] + asset_widget = self.data["model"]["assets"] asset_widget.set_silo(silo) asset_widget.select_assets([asset], expand=True) @@ -358,8 +350,8 @@ def _build_menu(self): if group: group.deleteLater() - active_groups = get_active_group_config(self.asset_id, - include_predefined=True) + active_groups = lib.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"]): @@ -380,8 +372,8 @@ 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): + with lib.preserve_selection(tree_view=self.subsets.view, + current_index=False): self.grouped.emit() self.close() @@ -435,16 +427,16 @@ def show(debug=False, parent=None, use_context=False): with lib.application(): # TODO: Global state, remove these - refresh_family_config() - refresh_group_config() + lib.refresh_family_config_cache() + lib.refresh_group_config_cache() window = Window(parent) window.setStyleSheet(style.load_stylesheet()) window.show() if use_context: - context = {"asset": api.Session['AVALON_ASSET'], - "silo": api.Session['AVALON_SILO']} + context = {"asset": api.Session["AVALON_ASSET"], + "silo": api.Session["AVALON_SILO"]} window.set_context(context, refresh=True) else: window.refresh() diff --git a/avalon/tools/loader/delegates.py b/avalon/tools/loader/delegates.py index 7b9772e45..67179c6cb 100644 --- a/avalon/tools/loader/delegates.py +++ b/avalon/tools/loader/delegates.py @@ -2,9 +2,7 @@ from datetime import datetime import logging -from ...vendor.Qt import QtWidgets, QtCore -from ... import io -from .model import SubsetsModel +from ...vendor.Qt import QtWidgets log = logging.getLogger(__name__) @@ -36,7 +34,7 @@ def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"): # future (consider as just now) if day_diff < 0: - return 'just now' + return "just now" # history if day_diff == 0: @@ -102,70 +100,3 @@ class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): return pretty_timestamp(value) - - -class VersionDelegate(QtWidgets.QStyledItemDelegate): - """A delegate that display version integer formatted as version string.""" - - version_changed = QtCore.Signal() - first_run = False - lock = False - - def _format_version(self, value): - """Formats integer to displayable version name""" - return "v{0:03d}".format(value) - - def displayText(self, value, locale): - assert isinstance(value, int), "Version is not `int`" - 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(): - if not self.first_run: - self.commitData.emit(editor) # Update model data - self.version_changed.emit() # Display model data - editor.currentIndexChanged.connect(commit_data) - - self.first_run = True - self.lock = False - - return editor - - def setEditorData(self, editor, index): - if self.lock: - # Only set editor data once per delegation - return - - editor.clear() - - # Current value of the index - value = index.data(QtCore.Qt.DisplayRole) - assert isinstance(value, int), "Version is not `int`" - - # Add all available versions to the editor - node = index.data(SubsetsModel.NodeRole) - parent_id = node['version_document']['parent'] - versions = io.find({"type": "version", "parent": parent_id}, - sort=[("name", 1)]) - index = 0 - for i, version in enumerate(versions): - label = self._format_version(version['name']) - editor.addItem(label, userData=version) - - if version['name'] == value: - index = i - - editor.setCurrentIndex(index) # Will trigger index-change signal - self.first_run = False - self.lock = True - - def setModelData(self, editor, model, index): - """Apply the integer version back in the model""" - version = editor.itemData(editor.currentIndex()) - model.setData(index, version['name']) diff --git a/avalon/tools/loader/lib.py b/avalon/tools/loader/lib.py deleted file mode 100644 index caa5edf5a..000000000 --- a/avalon/tools/loader/lib.py +++ /dev/null @@ -1,182 +0,0 @@ -from ...vendor import qtawesome, Qt -from ... import io, api, style - -FAMILY_ICON_COLOR = "#0091B2" -FAMILY_CONFIG = {} -GROUP_CONFIG = {} - - -def get(config, name): - """Get value from config with fallback to default""" - # We assume the default fallback key in the config is `__default__` - 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 - - The configuration must be stored on the project under `config`. - For example: - - { - "config": { - "families": [ - {"name": "avalon.camera", label: "Camera", "icon": "photo"}, - {"name": "avalon.anim", label: "Animation", "icon": "male"}, - ] - } - } - - It is possible to override the default behavior and set specific families - checked. For example we only want the families imagesequence and camera - to be visible in the Loader. - - # This will turn every item off - api.data["familyStateDefault"] = False - - # Only allow the imagesequence and camera - api.data["familyStateToggled"] = ["imagesequence", "camera"] - - """ - # Update the icons from the project configuration - project = io.find_one({"type": "project"}, - projection={"config.families": True}) - - assert project, "Project not found!" - families = project['config'].get("families", []) - families = {family['name']: family for family in families} - - # Check if any family state are being overwritten by the configuration - default_state = api.data.get("familiesStateDefault", True) - toggled = set(api.data.get("familiesStateToggled", [])) - - # Replace icons with a Qt icon we can use in the user interfaces - default_icon = qtawesome.icon("fa.folder", color=FAMILY_ICON_COLOR) - for name, family in families.items(): - # Set family icon - icon = family.get("icon", None) - if icon: - family['icon'] = qtawesome.icon("fa.{}".format(icon), - color=FAMILY_ICON_COLOR) - else: - family['icon'] = default_icon - - # Update state - state = not default_state if name in toggled else default_state - family["state"] = state - - # Default configuration - families["__default__"] = {"icon": default_icon} - - FAMILY_CONFIG.clear() - 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/loader/model.py b/avalon/tools/loader/model.py index 289bcb446..564746ff4 100644 --- a/avalon/tools/loader/model.py +++ b/avalon/tools/loader/model.py @@ -1,17 +1,23 @@ from ... import io, style from ...vendor.Qt import QtCore -from ...vendor import qtawesome as qta +from ...vendor import qtawesome -from ..projectmanager.model import ( - TreeModel, - Node -) +from ..models import TreeModel, Item +from .. import lib -from . import lib + +def is_filtering_recursible(): + """Does Qt binding support recursive filtering for QSortFilterProxyModel? + + (NOTE) Recursive filtering was introduced in Qt 5.10. + + """ + return hasattr(QtCore.QSortFilterProxyModel, + "setRecursiveFilteringEnabled") class SubsetsModel(TreeModel): - COLUMNS = ["subset", + Columns = ["subset", "family", "version", "time", @@ -29,8 +35,8 @@ def __init__(self, grouping=True, parent=None): self._asset_id = None self._sorter = None self._grouping = grouping - self._icons = {"subset": qta.icon("fa.file-o", - color=style.colors.default)} + self._icons = {"subset": qtawesome.icon("fa.file-o", + color=style.colors.default)} def set_asset(self, asset_id): self._asset_id = asset_id @@ -45,8 +51,8 @@ def setData(self, index, value, role=QtCore.Qt.EditRole): # Trigger additional edit when `version` column changed # because it also updates the information in other columns if index.column() == 2: - node = index.internalPointer() - parent = node["_id"] + item = index.internalPointer() + parent = item["_id"] version = io.find_one({"name": value, "type": "version", "parent": parent}) @@ -58,14 +64,17 @@ def set_version(self, index, version): """Update the version data of the given index. Arguments: - version (dict) Version document in the database. """ + index (QtCore.QModelIndex): The model index. + version (dict) Version document in the database. + + """ assert isinstance(index, QtCore.QModelIndex) if not index.isValid(): return - node = index.internalPointer() - assert version['parent'] == node['_id'], ("Version does not " + item = index.internalPointer() + assert version["parent"] == item["_id"], ("Version does not " "belong to subset") # Get the data from the version @@ -93,8 +102,8 @@ def set_version(self, index, version): if frame_start is not None and frame_end is not None: # Remove superfluous zeros from numbers (3.0 -> 3) to improve # readability for most frame ranges - start_clean = ('%f' % frame_start).rstrip('0').rstrip('.') - end_clean = ('%f' % frame_end).rstrip('0').rstrip('.') + start_clean = ("%f" % frame_start).rstrip("0").rstrip(".") + end_clean = ("%f" % frame_end).rstrip("0").rstrip(".") frames = "{0}-{1}".format(start_clean, end_clean) duration = frame_end - frame_start + 1 else: @@ -103,16 +112,16 @@ def set_version(self, index, version): families = version_data.get("families", [None]) family = families[0] - family_config = lib.get(lib.FAMILY_CONFIG, family) + family_config = lib.get_family_cached_config(family) - node.update({ - "version": version['name'], + item.update({ + "version": version["name"], "version_document": version, "author": version_data.get("author", None), "time": version_data.get("time", None), "family": family, "familyLabel": family_config.get("label", family), - "familyIcon": family_config.get('icon', None), + "familyIcon": family_config.get("icon", None), "families": set(families), "frameStart": frame_start, "frameEnd": frame_end, @@ -135,22 +144,22 @@ def refresh(self): active_groups = lib.get_active_group_config(asset_id) # Generate subset group nodes - group_nodes = dict() + group_items = dict() if self._grouping: for data in active_groups: name = data.pop("name") - group = Node() + group = Item() group.update({"subset": name, "isGroup": True, "childRow": 0}) group.update(data) - group_nodes[name] = group + group_items[name] = group self.add_child(group) filter = {"type": "subset", "parent": asset_id} # Process subsets - row = len(group_nodes) + row = len(group_items) for subset in io.find(filter): last_version = io.find_one({"type": "version", @@ -165,7 +174,7 @@ def refresh(self): group_name = subset["data"].get("subsetGroup") if self._grouping and group_name: - group = group_nodes[group_name] + group = group_items[group_name] parent = group parent_index = self.createIndex(0, 0, group) row_ = group["childRow"] @@ -176,10 +185,10 @@ def refresh(self): row_ = row row += 1 - node = Node() - node.update(data) + item = Item() + item.update(data) - self.add_child(node, parent=parent) + self.add_child(item, parent=parent) # Set the version information index = self.index(row_, 0, parent=parent_index) @@ -195,30 +204,30 @@ def data(self, index, role): if role == QtCore.Qt.DisplayRole: if index.column() == 1: # Show familyLabel instead of family - node = index.internalPointer() - return node.get("familyLabel", None) + item = index.internalPointer() + return item.get("familyLabel", None) if role == QtCore.Qt.DecorationRole: # Add icon to subset column if index.column() == 0: - node = index.internalPointer() - if node.get("isGroup"): - return node["icon"] + item = index.internalPointer() + if item.get("isGroup"): + return item["icon"] else: return self._icons["subset"] # Add icon to family column if index.column() == 1: - node = index.internalPointer() - return node.get("familyIcon", None) + item = index.internalPointer() + return item.get("familyIcon", None) if role == self.SortDescendingRole: - node = index.internalPointer() - if node.get("isGroup"): + item = index.internalPointer() + if item.get("isGroup"): # Ensure groups be on top when sorting by descending order prefix = "1" - order = node["inverseOrder"] + order = item["inverseOrder"] else: prefix = "0" order = str(super(SubsetsModel, @@ -226,11 +235,11 @@ def data(self, index, role): return prefix + order if role == self.SortAscendingRole: - node = index.internalPointer() - if node.get("isGroup"): + item = index.internalPointer() + if item.get("isGroup"): # Ensure groups be on top when sorting by ascending order prefix = "0" - order = node["order"] + order = item["order"] else: prefix = "1" order = str(super(SubsetsModel, @@ -257,7 +266,7 @@ class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel): """ - if lib.is_filtering_recursible(): + if 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. @@ -289,8 +298,8 @@ def filterAcceptsRow(self, row, parent): index = model.index(row, self.filterKeyColumn(), parent) - node = index.internalPointer() - if node.get("isGroup"): + item = index.internalPointer() + if item.get("isGroup"): return self.filter_accepts_group(index, model) else: return super(SubsetFilterProxyModel, @@ -326,16 +335,16 @@ def filterAcceptsRow(self, row=0, parent=QtCore.QModelIndex()): return True # Get the node data and validate - node = model.data(index, TreeModel.NodeRole) + item = model.data(index, TreeModel.ItemRole) - if node.get("isGroup"): + if item.get("isGroup"): return self.filter_accepts_group(index, model) - families = node.get("families", []) + families = item.get("families", []) filterable_families = set() for name in families: - family_config = lib.get(lib.FAMILY_CONFIG, name) + family_config = lib.get_family_cached_config(name) if not family_config.get("hideFilter"): filterable_families.add(name) diff --git a/avalon/tools/loader/widgets.py b/avalon/tools/loader/widgets.py index c671ca53c..72cdd7436 100644 --- a/avalon/tools/loader/widgets.py +++ b/avalon/tools/loader/widgets.py @@ -8,15 +8,15 @@ from ... import api from ... import pipeline -from ..projectmanager.widget import preserve_selection +from .. import lib as tools_lib +from ..delegates import VersionDelegate from .model import ( SubsetsModel, SubsetFilterProxyModel, FamiliesFilterProxyModel, ) -from .delegates import PrettyTimeDelegate, VersionDelegate -from . import lib +from .delegates import PrettyTimeDelegate class SubsetWidget(QtWidgets.QWidget): @@ -55,11 +55,11 @@ def __init__(self, enable_grouping=True, parent=None): # Set view delegates version_delegate = VersionDelegate() - column = model.COLUMNS.index("version") + column = model.Columns.index("version") view.setItemDelegateForColumn(column, version_delegate) time_delegate = PrettyTimeDelegate() - column = model.COLUMNS.index("time") + column = model.Columns.index("time") view.setItemDelegateForColumn(column, time_delegate) layout = QtWidgets.QVBoxLayout(self) @@ -121,8 +121,8 @@ 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): + with tools_lib.preserve_selection(tree_view=self.view, + current_index=False): self.model.set_grouping(state) def on_context_menu(self, point): @@ -131,7 +131,7 @@ def on_context_menu(self, point): if not point_index.isValid(): return - node = point_index.data(self.model.NodeRole) + node = point_index.data(self.model.ItemRole) if node.get("isGroup"): return @@ -140,13 +140,13 @@ def on_context_menu(self, point): available_loaders = api.discover(api.Loader) loaders = list() - version_id = node['version_document']['_id'] + version_id = node["version_document"]["_id"] representations = io.find({"type": "representation", "parent": version_id}) for representation in representations: for loader in api.loaders_from_representation( available_loaders, - representation['_id'] + representation["_id"] ): loaders.append((representation, loader)) @@ -170,7 +170,7 @@ def sorter(value): label = loader.__name__ # Add the representation as suffix - label = "{0} ({1})".format(label, representation['name']) + label = "{0} ({1})".format(label, representation["name"]) action = QtWidgets.QAction(label, menu) action.setData((representation, loader)) @@ -203,7 +203,7 @@ def sorter(value): # Find the representation name and loader to trigger action_representation, loader = action.data() - representation_name = action_representation['name'] # extension + representation_name = action_representation["name"] # extension # Run the loader for all selected indices, for those that have the # same representation available @@ -223,7 +223,7 @@ def sorter(value): # Trigger for row in rows: - node = row.data(self.model.NodeRole) + node = row.data(self.model.ItemRole) if node.get("isGroup"): continue @@ -250,7 +250,7 @@ def selected_subsets(self): subsets = list() for row in rows: - node = row.data(self.model.NodeRole) + node = row.data(self.model.ItemRole) if not node.get("isGroup"): subsets.append(node) @@ -319,7 +319,7 @@ def set_version(self, version_id): version = io.find_one({"_id": version_id, "type": "version"}) assert version, "Not a valid version id" - subset = io.find_one({"_id": version['parent'], "type": "subset"}) + subset = io.find_one({"_id": version["parent"], "type": "subset"}) assert subset, "No valid subset parent for version" # Define readable creation timestamp @@ -327,18 +327,18 @@ def set_version(self, version_id): created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ") created = datetime.datetime.strftime(created, "%b %d %Y %H:%M") - comment = version['data'].get("comment", None) or "No comment" + comment = version["data"].get("comment", None) or "No comment" - source = version['data'].get("source", None) + source = version["data"].get("source", None) source_label = source if source else "No source" # Store source and raw data - self.data['source'] = source - self.data['raw'] = version + self.data["source"] = source + self.data["raw"] = version data = { - "subset": subset['name'], - "version": version['name'], + "subset": subset["name"], + "version": version["name"], "comment": comment, "created": created, "source": source_label @@ -456,7 +456,7 @@ def refresh(self): self.clear() for name in sorted(unique_families): - family = lib.get(lib.FAMILY_CONFIG, name) + family = tools_lib.get_family_cached_config(name) if family.get("hideFilter"): continue diff --git a/avalon/tools/models.py b/avalon/tools/models.py new file mode 100644 index 000000000..23ebeca73 --- /dev/null +++ b/avalon/tools/models.py @@ -0,0 +1,451 @@ +import re +import logging +import collections + +from ..vendor.Qt import QtCore, QtGui +from ..vendor import qtawesome +from .. import io +from .. import style + +log = logging.getLogger(__name__) + + +class TreeModel(QtCore.QAbstractItemModel): + + Columns = list() + ItemRole = QtCore.Qt.UserRole + 1 + + def __init__(self, parent=None): + super(TreeModel, self).__init__(parent) + self._root_item = Item() + + def rowCount(self, parent): + if parent.isValid(): + item = parent.internalPointer() + else: + item = self._root_item + + return item.childCount() + + def columnCount(self, parent): + return len(self.Columns) + + def data(self, index, role): + + if not index.isValid(): + return None + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + + item = index.internalPointer() + column = index.column() + + key = self.Columns[column] + return item.get(key, None) + + if role == self.ItemRole: + return index.internalPointer() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + """Change the data on the items. + + Returns: + bool: Whether the edit was successful + """ + + if index.isValid(): + if role == QtCore.Qt.EditRole: + + item = index.internalPointer() + column = index.column() + key = self.Columns[column] + item[key] = value + + # passing `list()` for PyQt5 (see PYSIDE-462) + self.dataChanged.emit(index, index, list()) + + # must return true if successful + return True + + return False + + def setColumns(self, keys): + assert isinstance(keys, (list, tuple)) + self.Columns = keys + + def headerData(self, section, orientation, role): + + if role == QtCore.Qt.DisplayRole: + if section < len(self.Columns): + return self.Columns[section] + + super(TreeModel, self).headerData(section, orientation, role) + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled + + item = index.internalPointer() + if item.get("enabled", True): + flags |= QtCore.Qt.ItemIsSelectable + + return flags + + def parent(self, index): + + item = index.internalPointer() + parent_item = item.parent() + + # If it has no parents we return invalid + if parent_item == self._root_item or not parent_item: + return QtCore.QModelIndex() + + return self.createIndex(parent_item.row(), 0, parent_item) + + def index(self, row, column, parent): + """Return index for row/column under parent""" + + if not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + + child_item = parent_item.child(row) + if child_item: + return self.createIndex(row, column, child_item) + else: + return QtCore.QModelIndex() + + def add_child(self, item, parent=None): + if parent is None: + parent = self._root_item + + parent.add_child(item) + + def column_name(self, column): + """Return column key by index""" + + if column < len(self.Columns): + return self.Columns[column] + + def clear(self): + self.beginResetModel() + self._root_item = Item() + self.endResetModel() + + +class Item(dict): + """An item that can be represented in a tree view using `TreeModel`. + + The item can store data just like a regular dictionary. + + >>> data = {"name": "John", "score": 10} + >>> item = Item(data) + >>> assert item["name"] == "John" + + """ + + def __init__(self, data=None): + super(Item, self).__init__() + + self._children = list() + self._parent = None + + if data is not None: + assert isinstance(data, dict) + self.update(data) + + def childCount(self): + return len(self._children) + + def child(self, row): + + if row >= len(self._children): + log.warning("Invalid row as child: {0}".format(row)) + return + + return self._children[row] + + def children(self): + return self._children + + def parent(self): + return self._parent + + def row(self): + """ + Returns: + int: Index of this item under parent""" + if self._parent is not None: + siblings = self.parent().children() + return siblings.index(self) + + def add_child(self, child): + """Add a child to this item""" + child._parent = self + self._children.append(child) + + +class TasksModel(TreeModel): + """A model listing the tasks combined for a list of assets""" + + Columns = ["name", "count"] + + def __init__(self): + super(TasksModel, self).__init__() + self._num_assets = 0 + self._icons = { + "__default__": qtawesome.icon("fa.male", + color=style.colors.default) + } + + self._get_task_icons() + + def _get_task_icons(self): + # Get the project configured icons from database + project = io.find_one({"type": "project"}) + tasks = project["config"].get("tasks", []) + for task in tasks: + icon_name = task.get("icon", None) + if icon_name: + icon = qtawesome.icon("fa.{}".format(icon_name), + color=style.colors.default) + self._icons[task["name"]] = icon + + def set_assets(self, asset_ids): + """Set assets to track by their database id + + Arguments: + asset_ids (list): List of asset ids. + + """ + + assets = list() + for asset_id in asset_ids: + asset = io.find_one({"_id": asset_id, "type": "asset"}) + assert asset, "Asset not found by id: {0}".format(asset_id) + assets.append(asset) + + self._num_assets = len(assets) + + tasks = collections.Counter() + for asset in assets: + asset_tasks = asset.get("data", {}).get("tasks", []) + tasks.update(asset_tasks) + + self.clear() + self.beginResetModel() + + default_icon = self._icons["__default__"] + + if not tasks: + item = Item({ + "name": "No task", + "count": 0, + "icon": default_icon, + "enabled": False, + }) + + self.add_child(item) + + else: + for task, count in sorted(tasks.items()): + icon = self._icons.get(task, default_icon) + + item = Item({ + "name": task, + "count": count, + "icon": icon + }) + + self.add_child(item) + + self.endResetModel() + + def headerData(self, section, orientation, role): + + # Override header for count column to show amount of assets + # it is listing the tasks for + if role == QtCore.Qt.DisplayRole: + if orientation == QtCore.Qt.Horizontal: + if section == 1: # count column + return "count ({0})".format(self._num_assets) + + return super(TasksModel, self).headerData(section, orientation, role) + + def data(self, index, role): + + if not index.isValid(): + return + + # Add icon to the first column + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + return index.internalPointer()["icon"] + + return super(TasksModel, self).data(index, role) + + +class AssetModel(TreeModel): + """A model listing assets in the silo in the active project. + + The assets are displayed in a treeview, they are visually parented by + a `visualParent` field in the database containing an `_id` to a parent + asset. + + """ + + Columns = ["label"] + Name = 0 + Deprecated = 2 + ObjectId = 3 + + DocumentRole = QtCore.Qt.UserRole + 2 + ObjectIdRole = QtCore.Qt.UserRole + 3 + + def __init__(self, silo=None, parent=None): + super(AssetModel, self).__init__(parent=parent) + + self._silo = None + + if silo is not None: + self.set_silo(silo, refresh=True) + + def set_silo(self, silo, refresh=True): + """Set the root path to the ItemType root.""" + self._silo = silo + if refresh: + self.refresh() + + def _add_hierarchy(self, parent=None): + + # Find the assets under the parent + find_data = { + "type": "asset", + "silo": self._silo, + } + if parent is None: + # if not a parent find all that are parented to the project + # or do *not* have a visualParent field at all + find_data["$or"] = [ + {"data.visualParent": {"$exists": False}}, + {"data.visualParent": None} + ] + else: + find_data["data.visualParent"] = parent["_id"] + + assets = io.find(find_data).sort("name", 1) + + for asset in assets: + + # get label from data, otherwise use name + data = asset.get("data", {}) + label = data.get("label", asset["name"]) + tags = data.get("tags", []) + + # store for the asset for optimization + deprecated = "deprecated" in tags + + item = Item({ + "_id": asset["_id"], + "name": asset["name"], + "label": label, + "type": asset["type"], + "tags": ", ".join(tags), + "deprecated": deprecated, + "_document": asset + }) + self.add_child(item, parent=parent) + + # Add asset's children recursively + self._add_hierarchy(item) + + def refresh(self): + """Refresh the data for the model.""" + + self.clear() + self.beginResetModel() + if self._silo: + self._add_hierarchy(parent=None) + self.endResetModel() + + def flags(self, index): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + def data(self, index, role): + + if not index.isValid(): + return + + item = index.internalPointer() + if role == QtCore.Qt.DecorationRole: # icon + + column = index.column() + if column == self.Name: + + # Allow a custom icon and custom icon color to be defined + data = item["_document"]["data"] + icon = data.get("icon", None) + color = data.get("color", style.colors.default) + + if icon is None: + # Use default icons if no custom one is specified. + # If it has children show a full folder, otherwise + # show an open folder + has_children = self.rowCount(index) > 0 + icon = "folder" if has_children else "folder-o" + + # Make the color darker when the asset is deprecated + if item.get("deprecated", False): + color = QtGui.QColor(color).darker(250) + + try: + key = "fa.{0}".format(icon) # font-awesome key + icon = qtawesome.icon(key, color=color) + return icon + except Exception as exception: + # Log an error message instead of erroring out completely + # when the icon couldn't be created (e.g. invalid name) + log.error(exception) + + return + + if role == QtCore.Qt.ForegroundRole: # font color + if "deprecated" in item.get("tags", []): + return QtGui.QColor(style.colors.light).darker(250) + + if role == self.ObjectIdRole: + return item.get("_id", None) + + if role == self.DocumentRole: + return item.get("_document", None) + + return super(AssetModel, self).data(index, role) + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters to the regex if any of the children matches allow parent""" + def filterAcceptsRow(self, row, parent): + + regex = self.filterRegExp() + if not regex.isEmpty(): + pattern = regex.pattern() + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if source_index.isValid(): + + # Check current index itself + key = model.data(source_index, self.filterRole()) + if re.search(pattern, key, re.IGNORECASE): + return True + + # Check children + rows = model.rowCount(source_index) + for i in range(rows): + if self.filterAcceptsRow(i, source_index): + return True + + # Otherwise filter it + return False + + return super(RecursiveSortFilterProxyModel, + self).filterAcceptsRow(row, parent) diff --git a/avalon/tools/projectmanager/app.py b/avalon/tools/projectmanager/app.py index a43f56f4c..d50aca590 100644 --- a/avalon/tools/projectmanager/app.py +++ b/avalon/tools/projectmanager/app.py @@ -2,11 +2,12 @@ from ...vendor.Qt import QtWidgets, QtCore from ... import io, schema, api, style -from .. import lib as parentlib -from . import widget + +from .. import lib as tools_lib +from ..widgets import AssetWidget +from ..models import TasksModel from .dialogs import TasksCreateDialog, AssetCreateDialog -from .model import TasksModel module = sys.modules[__name__] module.window = None @@ -28,7 +29,7 @@ def __init__(self, parent=None): assets_widgets = QtWidgets.QWidget() assets_widgets.setContentsMargins(0, 0, 0, 0) assets_layout = QtWidgets.QVBoxLayout(assets_widgets) - assets = widget.AssetWidget() + assets = AssetWidget() assets.view.setSelectionMode(assets.view.ExtendedSelection) add_asset = QtWidgets.QPushButton("Add asset") assets_layout.addWidget(assets) @@ -133,7 +134,7 @@ def _on_asset_created(data): # This is to allow quick continuing of typing a new asset name # whenever the user created one; this way we can press the "ENTER" # key to add an asset and continue typing for the next. - dialog.data['label']['label'].setFocus() + dialog.data["label"]["label"].setFocus() def _on_current_asset_changed(): """Callback on current asset changed in item widget. @@ -183,7 +184,7 @@ def on_add_task(self): asset_tasks.append(task) # Update the field - asset['data']['tasks'] = asset_tasks + asset["data"]["tasks"] = asset_tasks schema.validate(asset) io.replace_one(_filter, asset) @@ -202,7 +203,7 @@ def on_asset_changed(self): model = self.data["model"]["assets"] selected = model.get_selected_assets() - self.data['model']['tasks'].set_assets(selected) + self.data["model"]["tasks"].set_assets(selected) def on_silo_changed(self, silo): """Callback on asset silo changed""" @@ -216,6 +217,8 @@ def show(root=None, debug=False, parent=None): Arguments: debug (bool, optional): Run loader in debug-mode, defaults to False + parent (QtCore.QObject, optional): When provided parent the interface + to this QObject. """ @@ -228,7 +231,7 @@ def show(root=None, debug=False, parent=None): if debug is True: io.install() - with parentlib.application(): + with tools_lib.application(): window = Window(parent) window.setStyleSheet(style.load_stylesheet()) window.show() diff --git a/avalon/tools/projectmanager/dialogs.py b/avalon/tools/projectmanager/dialogs.py index 0dd119fc1..9ccc53477 100644 --- a/avalon/tools/projectmanager/dialogs.py +++ b/avalon/tools/projectmanager/dialogs.py @@ -1,6 +1,7 @@ from ... import io -from ...vendor import qtawesome as qta +from ...vendor import qtawesome from ...vendor.Qt import QtWidgets, QtCore + from . import lib @@ -24,7 +25,7 @@ def __init__(self, parent=None): footer = QtWidgets.QHBoxLayout() cancel = QtWidgets.QPushButton("Cancel") - create = QtWidgets.QPushButton(qta.icon("fa.plus", color="grey"), + create = QtWidgets.QPushButton(qtawesome.icon("fa.plus", color="grey"), "Create") footer.addWidget(create) footer.addWidget(cancel) @@ -81,7 +82,7 @@ def __init__(self, parent=None): name.setReadOnly(True) name.setStyleSheet("background-color: #333333;") # greyed out - icon = qta.icon("fa.plus", color="grey") + icon = qtawesome.icon("fa.plus", color="grey") add_asset = QtWidgets.QPushButton(icon, "Add") add_asset.setAutoDefault(True) @@ -121,7 +122,7 @@ def set_parent(self, parent_id): if parent_id: parent_asset = io.find_one({"_id": parent_id, "type": "asset"}) assert parent_asset, "Parent asset does not exist." - parent_name = parent_asset['name'] + parent_name = parent_asset["name"] self.parent_name = parent_name self.parent_id = parent_id @@ -130,7 +131,7 @@ def set_parent(self, parent_id): self.parent_name = "" self.parent_id = None - self.data['label']['parent'].setText(parent_name) + self.data["label"]["parent"].setText(parent_name) def update_name(self): """Force an update on the long name. @@ -140,20 +141,20 @@ def update_name(self): """ - label = self.data['label']['label'].text() + label = self.data["label"]["label"].text() name = label # Prefix with parent name (if parent) if self.parent_name: name = self.parent_name + "_" + name - self.data['label']['name'].setText(name) + self.data["label"]["name"].setText(name) def on_add_asset(self): parent_id = self.parent_id - name = self.data['label']['name'].text() - label = self.data['label']['label'].text() + name = self.data["label"]["name"].text() + label = self.data["label"]["label"].text() silo = self.silo if not label: @@ -183,8 +184,8 @@ def on_add_asset(self): if parent_id: parent = io.find_one({"_id": io.ObjectId(parent_id)}) if parent: - group = parent['name'] - data['group'] = group + group = parent["name"] + data["group"] = group try: lib.create_asset(data) diff --git a/avalon/tools/projectmanager/lib.py b/avalon/tools/projectmanager/lib.py index e1d5e8aa7..4e7912d80 100644 --- a/avalon/tools/projectmanager/lib.py +++ b/avalon/tools/projectmanager/lib.py @@ -14,8 +14,7 @@ """ - -from avalon import schema, io +from ... import schema, io def create_asset(data): @@ -37,7 +36,7 @@ def create_asset(data): asset = { "schema": "avalon-core:asset-2.0", - "parent": project['_id'], + "parent": project["_id"], "name": data.pop("name"), "silo": data.pop("silo"), "type": "asset", @@ -45,16 +44,16 @@ def create_asset(data): } # Asset *must* have a name and silo - assert asset['name'], "Asset has no name" - assert asset['silo'], "Asset has no silo" + assert asset["name"], "Asset has no name" + assert asset["silo"], "Asset has no silo" # Ensure it has a unique name asset_doc = io.find_one({ - "name": asset['name'], + "name": asset["name"], "type": "asset", }) if asset_doc is not None: - raise RuntimeError("Asset '{}' already exists.".format(asset['name'])) + raise RuntimeError("Asset '{}' already exists.".format(asset["name"])) schema.validate(asset) io.insert_one(asset) @@ -63,4 +62,4 @@ def create_asset(data): def list_project_tasks(): """List the project task types available in the current project""" project = io.find_one({"type": "project"}) - return [task['name'] for task in project['config']['tasks']] + return [task["name"] for task in project["config"]["tasks"]] diff --git a/avalon/tools/projectmanager/model.py b/avalon/tools/projectmanager/model.py deleted file mode 100644 index 8eb10cbdc..000000000 --- a/avalon/tools/projectmanager/model.py +++ /dev/null @@ -1,355 +0,0 @@ -import re -import logging -import collections - -from ...vendor.Qt import QtCore, QtWidgets -from ...vendor import qtawesome as awesome -from ... import io -from ... import style - -log = logging.getLogger(__name__) - - -class Node(dict): - """A node that can be represented in a tree view. - - The node can store data just like a dictionary. - - >>> data = {"name": "John", "score": 10} - >>> node = Node(data) - >>> assert node["name"] == "John" - - """ - - def __init__(self, data=None): - super(Node, self).__init__() - - self._children = list() - self._parent = None - - if data is not None: - assert isinstance(data, dict) - self.update(data) - - def childCount(self): - return len(self._children) - - def child(self, row): - - if row >= len(self._children): - log.warning("Invalid row as child: {0}".format(row)) - return - - return self._children[row] - - def children(self): - return self._children - - def parent(self): - return self._parent - - def row(self): - """ - Returns: - int: Index of this node under parent""" - if self._parent is not None: - siblings = self.parent().children() - return siblings.index(self) - - def add_child(self, child): - """Add a child to this node""" - child._parent = self - self._children.append(child) - - -class TreeModel(QtCore.QAbstractItemModel): - - COLUMNS = list() - NodeRole = QtCore.Qt.UserRole + 1 - - def __init__(self, parent=None): - super(TreeModel, self).__init__(parent) - self._root_node = Node() - - def rowCount(self, parent): - if parent.isValid(): - node = parent.internalPointer() - else: - node = self._root_node - - return node.childCount() - - def columnCount(self, parent): - return len(self.COLUMNS) - - def data(self, index, role): - - if not index.isValid(): - return None - - if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: - - node = index.internalPointer() - column = index.column() - - key = self.COLUMNS[column] - return node.get(key, None) - - if role == self.NodeRole: - return index.internalPointer() - - def setData(self, index, value, role=QtCore.Qt.EditRole): - """Change the data on the nodes. - - Returns: - bool: Whether the edit was successful - """ - - if index.isValid(): - if role == QtCore.Qt.EditRole: - - node = index.internalPointer() - column = index.column() - key = self.COLUMNS[column] - node[key] = value - - # passing `list()` for PyQt5 (see PYSIDE-462) - self.dataChanged.emit(index, index, list()) - - # must return true if successful - return True - - return False - - def setColumns(self, keys): - assert isinstance(keys, (list, tuple)) - self.COLUMNS = keys - - def headerData(self, section, orientation, role): - - if role == QtCore.Qt.DisplayRole: - if section < len(self.COLUMNS): - return self.COLUMNS[section] - - super(TreeModel, self).headerData(section, orientation, role) - - def flags(self, index): - flags = QtCore.Qt.ItemIsEnabled - - node = index.internalPointer() - if node.get("enabled", True): - flags |= QtCore.Qt.ItemIsSelectable - - return flags - - def parent(self, index): - - node = index.internalPointer() - parent_node = node.parent() - - # If it has no parents we return invalid - if parent_node == self._root_node or not parent_node: - return QtCore.QModelIndex() - - return self.createIndex(parent_node.row(), 0, parent_node) - - def index(self, row, column, parent): - """Return index for row/column under parent""" - - if not parent.isValid(): - parentNode = self._root_node - else: - parentNode = parent.internalPointer() - - childItem = parentNode.child(row) - if childItem: - return self.createIndex(row, column, childItem) - else: - return QtCore.QModelIndex() - - def add_child(self, node, parent=None): - if parent is None: - parent = self._root_node - - parent.add_child(node) - - def column_name(self, column): - """Return column key by index""" - - if column < len(self.COLUMNS): - return self.COLUMNS[column] - - def clear(self): - self.beginResetModel() - self._root_node = Node() - self.endResetModel() - - -class TasksModel(TreeModel): - """A model listing the tasks combined for a list of assets""" - - COLUMNS = ["name", "count"] - - def __init__(self): - super(TasksModel, self).__init__() - self._num_assets = 0 - self._icons = { - "__default__": awesome.icon("fa.male", color=style.colors.default) - } - - self._get_task_icons() - - def _get_task_icons(self): - # Get the project configured icons from database - project = io.find_one({"type": "project"}) - tasks = project['config'].get('tasks', []) - for task in tasks: - icon_name = task.get("icon", None) - if icon_name: - icon = awesome.icon("fa.{}".format(icon_name), - color=style.colors.default) - self._icons[task["name"]] = icon - - def set_assets(self, asset_ids): - """Set assets to track by their database id - - Arguments: - asset_ids (list): List of asset ids. - - """ - - assets = list() - for asset_id in asset_ids: - asset = io.find_one({"_id": asset_id, "type": "asset"}) - assert asset, "Asset not found by id: {0}".format(asset_id) - assets.append(asset) - - self._num_assets = len(assets) - - tasks = collections.Counter() - for asset in assets: - asset_tasks = asset.get("data", {}).get("tasks", []) - tasks.update(asset_tasks) - - self.clear() - self.beginResetModel() - - default_icon = self._icons["__default__"] - - if not tasks: - node = Node({ - "name": "No task", - "count": 0, - "icon": default_icon, - "enabled": False, - }) - - self.add_child(node) - - else: - for task, count in sorted(tasks.items()): - icon = self._icons.get(task, default_icon) - - node = Node({ - "name": task, - "count": count, - "icon": icon - }) - - self.add_child(node) - - self.endResetModel() - - def headerData(self, section, orientation, role): - - # Override header for count column to show amount of assets - # it is listing the tasks for - if role == QtCore.Qt.DisplayRole: - if orientation == QtCore.Qt.Horizontal: - if section == 1: # count column - return "count ({0})".format(self._num_assets) - - return super(TasksModel, self).headerData(section, orientation, role) - - def data(self, index, role): - - if not index.isValid(): - return - - # Add icon to the first column - if role == QtCore.Qt.DecorationRole: - if index.column() == 0: - return index.internalPointer()['icon'] - - return super(TasksModel, self).data(index, role) - - -class DeselectableTreeView(QtWidgets.QTreeView): - """A tree view that deselects on clicking on an empty area in the view""" - - def mousePressEvent(self, event): - - index = self.indexAt(event.pos()) - if not index.isValid(): - # clear the selection - self.clearSelection() - # clear the current index - self.setCurrentIndex(QtCore.QModelIndex()) - - QtWidgets.QTreeView.mousePressEvent(self, event) - - -class ExactMatchesFilterProxyModel(QtCore.QSortFilterProxyModel): - """Filter model to where key column's value is in the filtered tags""" - - def __init__(self, *args, **kwargs): - super(ExactMatchesFilterProxyModel, self).__init__(*args, **kwargs) - self._filters = set() - - def setFilters(self, filters): - self._filters = set(filters) - - def filterAcceptsRow(self, source_row, source_parent): - - # No filter - if not self._filters: - return True - - else: - model = self.sourceModel() - column = self.filterKeyColumn() - idx = model.index(source_row, column, source_parent) - data = model.data(idx, self.filterRole()) - if data in self._filters: - return True - else: - return False - - -class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): - """Filters to the regex if any of the children matches allow parent""" - def filterAcceptsRow(self, row, parent): - - regex = self.filterRegExp() - if not regex.isEmpty(): - pattern = regex.pattern() - model = self.sourceModel() - source_index = model.index(row, self.filterKeyColumn(), parent) - if source_index.isValid(): - - # Check current index itself - key = model.data(source_index, self.filterRole()) - if re.search(pattern, key, re.IGNORECASE): - return True - - # Check children - rows = model.rowCount(source_index) - for i in range(rows): - if self.filterAcceptsRow(i, source_index): - return True - - # Otherwise filter it - return False - - return super(RecursiveSortFilterProxyModel, - self).filterAcceptsRow(row, parent) diff --git a/avalon/tools/projectmanager/widget.py b/avalon/tools/projectmanager/widget.py deleted file mode 100644 index 1b64a4adb..000000000 --- a/avalon/tools/projectmanager/widget.py +++ /dev/null @@ -1,601 +0,0 @@ -import logging -import contextlib - -from ...vendor import qtawesome as awesome -from ...vendor.Qt import QtWidgets, QtCore, QtGui -from ... import io -from ... import style - -from .model import ( - TreeModel, - Node, - RecursiveSortFilterProxyModel, - DeselectableTreeView -) - -log = logging.getLogger(__name__) - - -def _iter_model_rows(model, - column, - include_root=False): - """Iterate over all row indices in a model""" - indices = [QtCore.QModelIndex()] # start iteration at root - - for index in indices: - - # Add children to the iterations - child_rows = model.rowCount(index) - for child_row in range(child_rows): - child_index = model.index(child_row, column, index) - indices.append(child_index) - - if not include_root and not index.isValid(): - continue - - yield index - - -@contextlib.contextmanager -def preserve_expanded_rows(tree_view, - column=0, - role=QtCore.Qt.DisplayRole): - """Preserves expanded row in QTreeView by column's data role. - - This function is created to maintain the expand vs collapse status of - the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. - - Arguments: - tree_view (QWidgets.QTreeView): the tree view which is - nested in the application - column (int): the column to retrieve the data from - role (int): the role which dictates what will be returned - - Returns: - None - - """ - - model = tree_view.model() - - expanded = set() - - for index in _iter_model_rows(model, - column=column, - include_root=False): - if tree_view.isExpanded(index): - value = index.data(role) - expanded.add(value) - - try: - yield - finally: - if not expanded: - return - - for index in _iter_model_rows(model, - column=column, - include_root=False): - value = index.data(role) - state = value in expanded - if state: - tree_view.expand(index) - else: - tree_view.collapse(index) - - -@contextlib.contextmanager -def preserve_selection(tree_view, - column=0, - role=QtCore.Qt.DisplayRole, - current_index=True): - """Preserves row selection in QTreeView by column's data role. - - This function is created to maintain the selection status of - the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. - - tree_view (QWidgets.QTreeView): the tree view nested in the application - column (int): the column to retrieve the data from - role (int): the role which dictates what will be returned - - Returns: - None - - """ - - model = tree_view.model() - selection_model = tree_view.selectionModel() - flags = selection_model.Select | selection_model.Rows - - if current_index: - current_index_value = tree_view.currentIndex().data(role) - else: - current_index_value = None - - selected_rows = selection_model.selectedRows() - if not selected_rows: - yield - return - - selected = set(row.data(role) for row in selected_rows) - try: - yield - finally: - if not selected: - return - - # Go through all indices, select the ones with similar data - for index in _iter_model_rows(model, - column=column, - include_root=False): - - value = index.data(role) - state = value in selected - if state: - tree_view.scrollTo(index) # Ensure item is visible - selection_model.select(index, flags) - - if current_index_value and value == current_index_value: - selection_model.setCurrentIndex(index, - selection_model.NoUpdate) - - -def _list_project_silos(): - """List the silos from the project's configuration""" - silos = io.distinct("silo") - - if not silos: - project = io.find_one({"type": "project"}) - log.warning("Project '%s' has no active silos", project['name']) - - return list(sorted(silos)) - - -class AssetModel(TreeModel): - """A model listing assets in the silo in the active project. - - The assets are displayed in a treeview, they are visually parented by - a `visualParent` field in the database containing an `_id` to a parent - asset. - - """ - - COLUMNS = ["label"] - Name = 0 - Deprecated = 2 - ObjectId = 3 - - DocumentRole = QtCore.Qt.UserRole + 2 - ObjectIdRole = QtCore.Qt.UserRole + 3 - - def __init__(self, silo=None, parent=None): - super(AssetModel, self).__init__(parent=parent) - - self._silo = None - - if silo is not None: - self.set_silo(silo, refresh=True) - - def set_silo(self, silo, refresh=True): - """Set the root path to the ItemType root.""" - self._silo = silo - if refresh: - self.refresh() - - def _add_hierarchy(self, parent=None): - - # Find the assets under the parent - find_data = { - "type": "asset", - "silo": self._silo, - } - if parent is None: - # if not a parent find all that are parented to the project - # or do *not* have a visualParent field at all - find_data['$or'] = [ - {'data.visualParent': {'$exists': False}}, - {'data.visualParent': None} - ] - else: - find_data["data.visualParent"] = parent['_id'] - - assets = io.find(find_data).sort('name', 1) - - for asset in assets: - - # get label from data, otherwise use name - data = asset.get("data", {}) - label = data.get("label", asset['name']) - tags = data.get("tags", []) - - # store for the asset for optimization - deprecated = "deprecated" in tags - - node = Node({ - "_id": asset['_id'], - "name": asset["name"], - "label": label, - "type": asset['type'], - "tags": ", ".join(tags), - "deprecated": deprecated, - "_document": asset - }) - self.add_child(node, parent=parent) - - # Add asset's children recursively - self._add_hierarchy(node) - - def refresh(self): - """Refresh the data for the model.""" - - self.clear() - self.beginResetModel() - if self._silo: - self._add_hierarchy(parent=None) - self.endResetModel() - - def flags(self, index): - return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - - def data(self, index, role): - - if not index.isValid(): - return - - node = index.internalPointer() - if role == QtCore.Qt.DecorationRole: # icon - - column = index.column() - if column == self.Name: - - # Allow a custom icon and custom icon color to be defined - data = node["_document"]["data"] - icon = data.get("icon", None) - color = data.get("color", style.colors.default) - - if icon is None: - # Use default icons if no custom one is specified. - # If it has children show a full folder, otherwise - # show an open folder - has_children = self.rowCount(index) > 0 - icon = "folder" if has_children else "folder-o" - - # Make the color darker when the asset is deprecated - if node.get("deprecated", False): - color = QtGui.QColor(color).darker(250) - - try: - key = "fa.{0}".format(icon) # font-awesome key - icon = awesome.icon(key, color=color) - return icon - except Exception as exception: - # Log an error message instead of erroring out completely - # when the icon couldn't be created (e.g. invalid name) - log.error(exception) - - return - - if role == QtCore.Qt.ForegroundRole: # font color - if "deprecated" in node.get("tags", []): - return QtGui.QColor(style.colors.light).darker(250) - - if role == self.ObjectIdRole: - return node.get("_id", None) - - if role == self.DocumentRole: - return node.get("_document", None) - - return super(AssetModel, self).data(index, role) - - -class AssetView(DeselectableTreeView): - """Item view. - - This implements a context menu. - - """ - - def __init__(self): - super(AssetView, self).__init__() - self.setIndentation(15) - self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.setHeaderHidden(True) - - -class SiloTabWidget(QtWidgets.QTabBar): - """Silo widget - - Allows to add a silo, with "+" tab. - - Note: - When no silos are present an empty stub silo is added to - use as the "blank" tab to start on, so the + tab becomes - clickable. - - """ - - silo_changed = QtCore.Signal(str) - silo_added = QtCore.Signal(str) - - def __init__(self, silo_creatable=True, parent=None): - super(SiloTabWidget, self).__init__(parent=parent) - self.silo_creatable = silo_creatable - self._previous_tab_index = -1 - self.set_silos([]) - - self.setContentsMargins(0, 0, 0, 0) - self.setFixedHeight(28) - font = QtGui.QFont() - font.setBold(True) - self.setFont(font) - - self.currentChanged.connect(self.on_tab_changed) - - def on_tab_changed(self, index): - - if index == self._previous_tab_index: - return - - # If it's the last tab - num = self.count() - if self.silo_creatable and index == num - 1: - self.on_add_silo() - self.setCurrentIndex(self._previous_tab_index) - return - - silo = self.tabText(index) - self.silo_changed.emit(silo) - - # Store for the next calls - self._previous_tab_index = index - - def clear(self): - """Removes all tabs. - - Implemented similar to `QTabWidget.clear()` - - """ - for i in range(self.count()): - self.removeTab(0) - - def set_silos(self, silos): - - current_silo = self.get_current_silo() - - if not silos: - # Add an emtpy stub tab to start on. - silos = [""] - - # Populate the silos without emitting signals - self.blockSignals(True) - self.clear() - for silo in sorted(silos): - self.addTab(silo) - - if self.silo_creatable: - # Add the "+" tab - self.addTab("+") - - self.set_current_silo(current_silo) - self.blockSignals(False) - - # Assume the current index is "fine" - self._previous_tab_index = self.currentIndex() - - # Only emit a silo changed signal if the new signal - # after refresh is not the same as prior to it (e.g. - # when the silo was removed, or alike.) - if current_silo != self.get_current_silo(): - self.currentChanged.emit(self.currentIndex()) - - def set_current_silo(self, silo): - """Set the active silo by name or index. - - Args: - silo (str or int): The silo name or index. - emit (bool): Whether to emit the change signals - - """ - - # Already set - if silo == self.get_current_silo(): - return - - # Otherwise change the silo box to the name - for i in range(self.count()): - text = self.tabText(i) - if text == silo: - self.setCurrentIndex(i) - break - - def get_current_silo(self): - index = self.currentIndex() - return self.tabText(index) - - def on_add_silo(self): - silo, state = QtWidgets.QInputDialog.getText(self, - "Silo name", - "Create new silo:") - if not state or not silo: - return - - self.add_silo(silo) - - def get_silos(self): - """Return the currently available silos""" - - # Ignore first tab if empty - # Ignore the last tab because it is the "+" tab - silos = [] - for i in range(self.count() - 1): - label = self.tabText(i) - if i == 0 and not label: - continue - silos.append(label) - return silos - - def add_silo(self, silo): - - # Add the silo - silos = self.get_silos() - silos.append(silo) - silos = list(set(silos)) # ensure unique - self.set_silos(silos) - self.silo_added.emit(silo) - - self.set_current_silo(silo) - - -class AssetWidget(QtWidgets.QWidget): - """A Widget to display a tree of assets with filter - - To list the assets of the active project: - >>> # widget = AssetWidget() - >>> # widget.refresh() - >>> # widget.show() - - """ - - silo_changed = QtCore.Signal(str) # on silo combobox change - assets_refreshed = QtCore.Signal() # on model refresh - selection_changed = QtCore.Signal() # on view selection change - current_changed = QtCore.Signal() # on view current index change - - def __init__(self, silo_creatable=True, parent=None): - super(AssetWidget, self).__init__(parent=parent) - self.setContentsMargins(0, 0, 0, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(4) - - # Header - header = QtWidgets.QHBoxLayout() - - silo = SiloTabWidget(silo_creatable=silo_creatable) - - icon = awesome.icon("fa.refresh", color=style.colors.light) - refresh = QtWidgets.QPushButton(icon, "") - refresh.setToolTip("Refresh items") - - header.addWidget(silo) - header.addStretch(1) - header.addWidget(refresh) - - # Tree View - model = AssetModel() - proxy = RecursiveSortFilterProxyModel() - proxy.setSourceModel(model) - proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - view = AssetView() - view.setModel(proxy) - - filter = QtWidgets.QLineEdit() - filter.textChanged.connect(proxy.setFilterFixedString) - filter.setPlaceholderText("Filter assets..") - - # Layout - layout.addLayout(header) - layout.addWidget(filter) - layout.addWidget(view) - - # Signals/Slots - selection = view.selectionModel() - selection.selectionChanged.connect(self.selection_changed) - selection.currentChanged.connect(self.current_changed) - silo.silo_changed.connect(self._on_silo_changed) - refresh.clicked.connect(self.refresh) - - self.refreshButton = refresh - self.silo = silo - self.model = model - self.proxy = proxy - self.view = view - - def _on_silo_changed(self): - """Callback for silo change""" - - self._refresh_model() - silo = self.get_current_silo() - self.silo_changed.emit(silo) - self.selection_changed.emit() - - def _refresh_model(self): - - silo = self.get_current_silo() - with preserve_expanded_rows(self.view, - column=0, - role=self.model.ObjectIdRole): - with preserve_selection(self.view, - column=0, - role=self.model.ObjectIdRole): - self.model.set_silo(silo) - - self.assets_refreshed.emit() - - def refresh(self): - - silos = _list_project_silos() - self.silo.set_silos(silos) - - self._refresh_model() - - def get_current_silo(self): - """Returns the currently active silo.""" - return self.silo.get_current_silo() - - def get_active_asset(self): - """Return the asset id the current asset.""" - current = self.view.currentIndex() - return current.data(self.model.ObjectIdRole) - - def get_active_index(self): - return self.view.currentIndex() - - def get_selected_assets(self): - """Return the assets' ids that are selected.""" - selection = self.view.selectionModel() - rows = selection.selectedRows() - return [row.data(self.model.ObjectIdRole) for row in rows] - - def set_silo(self, silo): - """Set the active silo tab""" - self.silo.set_current_silo(silo) - - def select_assets(self, assets, expand=True): - """Select assets by name. - - Args: - assets (list): List of asset names - expand (bool): Whether to also expand to the asset in the view - - Returns: - None - - """ - # TODO: Instead of individual selection optimize for many assets - - assert isinstance(assets, - (tuple, list)), "Assets must be list or tuple" - - # Clear selection - selection_model = self.view.selectionModel() - selection_model.clearSelection() - - # Select - mode = selection_model.Select | selection_model.Rows - for index in _iter_model_rows(self.proxy, - column=0, - include_root=False): - data = index.data(self.model.NodeRole) - name = data['name'] - if name in assets: - selection_model.select(index, mode) - - if expand: - self.view.expand(index) - - # Set the currently active index - self.view.setCurrentIndex(index) diff --git a/avalon/tools/sceneinventory/app.py b/avalon/tools/sceneinventory/app.py index bc1691344..5360c3cf4 100644 --- a/avalon/tools/sceneinventory/app.py +++ b/avalon/tools/sceneinventory/app.py @@ -4,18 +4,11 @@ from functools import partial from ...vendor.Qt import QtWidgets, QtCore -from ...vendor import qtawesome as qta +from ...vendor import qtawesome from ... import io, api, style -from .. import lib as tools_lib -# todo(roy): refactor loading from other tools -from ..projectmanager.widget import ( - preserve_expanded_rows, - preserve_selection, - _iter_model_rows, -) -from ..loader.delegates import VersionDelegate -from ..loader.lib import refresh_family_config +from .. import lib as tools_lib +from ..delegates import VersionDelegate from .proxy import FilterProxyModel from .model import InventoryModel @@ -73,7 +66,7 @@ def _on_update_to_latest(items): api.update(item, -1) self.data_changed.emit() - update_icon = qta.icon("fa.angle-double-up", color=DEFAULT_COLOR) + update_icon = qtawesome.icon("fa.angle-double-up", color=DEFAULT_COLOR) updatetolatest_action = QtWidgets.QAction(update_icon, "Update to latest", menu) @@ -81,7 +74,7 @@ def _on_update_to_latest(items): lambda: _on_update_to_latest(items)) # set version - set_version_icon = qta.icon("fa.hashtag", color=DEFAULT_COLOR) + set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR) set_version_action = QtWidgets.QAction(set_version_icon, "Set version", menu) @@ -89,7 +82,7 @@ def _on_update_to_latest(items): lambda: self.show_version_dialog(items)) # switch asset - switch_asset_icon = qta.icon("fa.sitemap", color=DEFAULT_COLOR) + switch_asset_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR) switch_asset_action = QtWidgets.QAction(switch_asset_icon, "Switch Asset", menu) @@ -97,21 +90,21 @@ def _on_update_to_latest(items): lambda: self.show_switch_dialog(items)) # remove - remove_icon = qta.icon("fa.remove", color=DEFAULT_COLOR) + remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR) remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu) remove_action.triggered.connect( lambda: self.show_remove_warning_dialog(items)) # go back to flat view if self._hierarchy_view: - back_to_flat_icon = qta.icon("fa.list", color=DEFAULT_COLOR) + back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR) back_to_flat_action = QtWidgets.QAction(back_to_flat_icon, "Back to Full-View", menu) back_to_flat_action.triggered.connect(self.leave_hierarchy) # send items to hierarchy view - enter_hierarchy_icon = qta.icon("fa.indent", color="#d8d8d8") + enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8") enter_hierarchy_action = QtWidgets.QAction(enter_hierarchy_icon, "Cherry-Pick (Hierarchy)", menu) @@ -149,7 +142,7 @@ def _on_update_to_latest(items): for action in custom_actions: color = action.color or DEFAULT_COLOR - icon = qta.icon("fa.%s" % action.icon, color=color) + icon = qtawesome.icon("fa.%s" % action.icon, color=color) action_item = QtWidgets.QAction(icon, action.label, submenu) action_item.triggered.connect( partial(self.process_custom_action, action, items)) @@ -249,8 +242,8 @@ def select_items_by_action(self, object_names, options=None): "toggle": selection_model.Toggle, }[options.get("mode", "select")] - for item in _iter_model_rows(model, 0): - node = item.data(InventoryModel.NodeRole) + for item in tools_lib.iter_model_rows(model, 0): + node = item.data(InventoryModel.ItemRole) if node.get("isGroupNode"): continue @@ -287,7 +280,7 @@ def show_right_mouse_menu(self, pos): # Extend to the sub-items all_indices = self.extend_to_children(indices) - nodes = [dict(i.data(InventoryModel.NodeRole)) for i in all_indices + nodes = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices if i.parent().isValid()] if self._hierarchy_view: @@ -481,9 +474,9 @@ def __init__(self, parent=None, items=None): self._representations_box = SearchComboBox( placeholder="") - self._asset_label = QtWidgets.QLabel('') - self._subset_label = QtWidgets.QLabel('') - self._repre_label = QtWidgets.QLabel('') + self._asset_label = QtWidgets.QLabel("") + self._subset_label = QtWidgets.QLabel("") + self._repre_label = QtWidgets.QLabel("") main_layout = QtWidgets.QVBoxLayout() context_layout = QtWidgets.QHBoxLayout() @@ -491,7 +484,7 @@ def __init__(self, parent=None, items=None): subset_layout = QtWidgets.QVBoxLayout() repre_layout = QtWidgets.QVBoxLayout() - accept_icon = qta.icon("fa.check", color="white") + accept_icon = qtawesome.icon("fa.check", color="white") accept_btn = QtWidgets.QPushButton() accept_btn.setIcon(accept_icon) accept_btn.setFixedWidth(24) @@ -588,11 +581,11 @@ def set_labels(self): subset_label = default repre_label = default - if self._assets_box.currentText() != '': + if self._assets_box.currentText() != "": asset_label = self._assets_box.currentText() - if self._subsets_box.currentText() != '': + if self._subsets_box.currentText() != "": subset_label = self._subsets_box.currentText() - if self._representations_box.currentText() != '': + if self._representations_box.currentText() != "": repre_label = self._representations_box.currentText() self._asset_label.setText(asset_label) @@ -642,7 +635,7 @@ def validate(self): "type": "version", "parent": subset["_id"] }, - sort=[('name', -1)] + sort=[("name", -1)] ) if version is None: repre_ok = False @@ -656,22 +649,22 @@ def validate(self): if repre is None: repre_ok = False - asset_sheet = '' - subset_sheet = '' - repre_sheet = '' - accept_sheet = '' - error_msg = '*Please select' + asset_sheet = "" + subset_sheet = "" + repre_sheet = "" + accept_sheet = "" + error_msg = "*Please select" if asset_ok is False: - asset_sheet = 'border: 1px solid red;' + asset_sheet = "border: 1px solid red;" self._asset_label.setText(error_msg) if subset_ok is False: - subset_sheet = 'border: 1px solid red;' + subset_sheet = "border: 1px solid red;" self._subset_label.setText(error_msg) if repre_ok is False: - repre_sheet = 'border: 1px solid red;' + repre_sheet = "border: 1px solid red;" self._repre_label.setText(error_msg) if asset_ok and subset_ok and repre_ok: - accept_sheet = 'border: 1px solid green;' + accept_sheet = "border: 1px solid green;" self._assets_box.setStyleSheet(asset_sheet) self._subsets_box.setStyleSheet(subset_sheet) @@ -682,13 +675,13 @@ def validate(self): def _get_assets(self): filtered_assets = [] - for asset in io.find({'type': 'asset'}): + for asset in io.find({"type": "asset"}): subsets = io.find({ - 'type': 'subset', - 'parent': asset['_id'] + "type": "subset", + "parent": asset["_id"] }) for subs in subsets: - filtered_assets.append(asset['name']) + filtered_assets.append(asset["name"]) break return filtered_assets @@ -696,10 +689,10 @@ def _get_assets(self): def _get_subsets(self): # Filter subsets by asset in dropdown if self._assets_box.currentText() != "": - parents = [] + parents = list() parents.append(io.find_one({ - 'type': 'asset', - 'name': self._assets_box.currentText() + "type": "asset", + "name": self._assets_box.currentText() })) return self._get_document_names("subset", parents) @@ -717,12 +710,12 @@ def _get_subsets(self): possible_subsets = None for asset in assets: subsets = io.find({ - 'type': 'subset', - 'parent': asset['_id'] + "type": "subset", + "parent": asset["_id"] }) asset_subsets = set() for subset in subsets: - asset_subsets.add(subset['name']) + asset_subsets.add(subset["name"]) if possible_subsets is None: possible_subsets = asset_subsets @@ -739,16 +732,16 @@ def _get_representations(self): for subset in subsets: entity = io.find_one({ - 'type': 'subset', - 'name': subset + "type": "subset", + "name": subset }) entity = io.find_one( { - 'type': 'version', - 'parent': entity['_id'] + "type": "version", + "parent": entity["_id"] }, - sort=[('name', -1)] + sort=[("name", -1)] ) if entity not in parents: parents.append(entity) @@ -767,12 +760,12 @@ def _get_representations(self): possible_repres = None for version in versions: representations = io.find({ - 'type': 'representation', - 'parent': version['_id'] + "type": "representation", + "parent": version["_id"] }) repres = set() for repre in representations: - repres.add(repre['name']) + repres.add(repre["name"]) if possible_repres is None: possible_repres = repres @@ -847,7 +840,7 @@ def __init__(self, parent=None): outdated_only.setToolTip("Show outdated files only") outdated_only.setChecked(False) - icon = qta.icon("fa.refresh", color="white") + icon = qtawesome.icon("fa.refresh", color="white") refresh_button = QtWidgets.QPushButton() refresh_button.setIcon(icon) @@ -865,7 +858,7 @@ def __init__(self, parent=None): # apply delegates version_delegate = VersionDelegate(self) - column = model.COLUMNS.index("version") + column = model.Columns.index("version") view.setItemDelegateForColumn(column, version_delegate) layout.addLayout(control_layout) @@ -904,14 +897,14 @@ def __init__(self, parent=None): self.view.setColumnWidth(3, 150) # family self.view.setColumnWidth(4, 100) # namespace - refresh_family_config() + tools_lib.refresh_family_config_cache() def refresh(self): - with preserve_expanded_rows(tree_view=self.view, - role=self.model.UniqueRole): - with preserve_selection(tree_view=self.view, - role=self.model.UniqueRole, - current_index=False): + with tools_lib.preserve_expanded_rows(tree_view=self.view, + role=self.model.UniqueRole): + with tools_lib.preserve_selection(tree_view=self.view, + role=self.model.UniqueRole, + current_index=False): if self.view._hierarchy_view: self.model.refresh(selected=self.view._selected) else: @@ -924,6 +917,8 @@ def show(root=None, debug=False, parent=None): Arguments: debug (bool, optional): Run in debug-mode, defaults to False + parent (QtCore.QObject, optional): When provided parent the interface + to this QObject. """ diff --git a/avalon/tools/sceneinventory/lib.py b/avalon/tools/sceneinventory/lib.py index 25b7b8eac..379991f1e 100644 --- a/avalon/tools/sceneinventory/lib.py +++ b/avalon/tools/sceneinventory/lib.py @@ -54,7 +54,7 @@ def switch_item(container, version = io.find_one({"type": "version", "parent": subset["_id"]}, - sort=[('name', -1)]) + sort=[("name", -1)]) assert version, "Could not find a version for {}.{}".format( asset_name, subset_name @@ -74,8 +74,7 @@ def switch_item(container, def walk_hierarchy(node): - """Recursively yield group node - """ + """Recursively yield group node""" for child in node.children(): if child.get("isGroupNode"): yield child diff --git a/avalon/tools/sceneinventory/model.py b/avalon/tools/sceneinventory/model.py index 648d5d1b7..d7a1276e1 100644 --- a/avalon/tools/sceneinventory/model.py +++ b/avalon/tools/sceneinventory/model.py @@ -1,24 +1,21 @@ - import logging from collections import defaultdict from ... import api, io, style from ...vendor.Qt import QtCore, QtGui -from ...vendor import qtawesome as qta -from .lib import walk_hierarchy +from ...vendor import qtawesome + +from .. import lib as tools_lib +from ..models import TreeModel, Item -# todo(roy): refactor loading from other tools -from ..loader import lib as loader_lib -from ..projectmanager.model import ( - TreeModel, Node -) +from . import lib class InventoryModel(TreeModel): """The model for the inventory""" - COLUMNS = ["Name", "version", "count", "family", "objectName"] + Columns = ["Name", "version", "count", "family", "objectName"] OUTDATED_COLOR = QtGui.QColor(235, 30, 30) CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30) @@ -37,11 +34,11 @@ def data(self, index, role): if not index.isValid(): return - node = index.internalPointer() + item = index.internalPointer() if role == QtCore.Qt.FontRole: # Make top-level entries bold - if node.get("isGroupNode"): # group-item + if item.get("isGroupNode"): # group-item font = QtGui.QFont() font.setBold(True) return font @@ -49,17 +46,17 @@ def data(self, index, role): if role == QtCore.Qt.ForegroundRole: # Set the text color to the OUTDATED_COLOR when the # collected version is not the same as the highest version - key = self.COLUMNS[index.column()] + key = self.Columns[index.column()] outdated = (lambda n: n.get("version") != n.get("highest_version")) if key == "version": # version - if node.get("isGroupNode"): # group-item - if outdated(node): + if item.get("isGroupNode"): # group-item + if outdated(item): return self.OUTDATED_COLOR if self._hierarchy_view: # If current group is not outdated, check if any # outdated children. - for _node in walk_hierarchy(node): + for _node in lib.walk_hierarchy(item): if outdated(_node): return self.CHILD_OUTDATED_COLOR else: @@ -67,31 +64,31 @@ def data(self, index, role): if self._hierarchy_view: # Although this is not a group item, we still need # to distinguish which one contain outdated child. - for _node in walk_hierarchy(node): + for _node in lib.walk_hierarchy(item): if outdated(_node): return self.CHILD_OUTDATED_COLOR.darker(150) return self.GRAYOUT_COLOR - if key == "Name" and not node.get("isGroupNode"): + if key == "Name" and not item.get("isGroupNode"): return self.GRAYOUT_COLOR # Add icons if role == QtCore.Qt.DecorationRole: if index.column() == 0: # Override color - color = node.get("color", style.colors.default) - if node.get("isGroupNode"): # group-item - return qta.icon("fa.folder", color=color) + color = item.get("color", style.colors.default) + if item.get("isGroupNode"): # group-item + return qtawesome.icon("fa.folder", color=color) else: - return qta.icon("fa.file-o", color=color) + return qtawesome.icon("fa.file-o", color=color) if index.column() == 3: # Family icon - return node.get("familyIcon", None) + return item.get("familyIcon", None) if role == self.UniqueRole: - return node["representation"] + node.get("objectName", "") + return item["representation"] + item.get("objectName", "") return super(InventoryModel, self).data(index, role) @@ -142,7 +139,7 @@ def walk_children(names): # Parent not in selection, this is root item. item["parent"] = None - parents = [self._root_node] + parents = [self._root_item] # The length of `items` array is the maximum depth that a # hierarchy could be. @@ -203,9 +200,11 @@ def add_items(self, items, parent=None): Args: items (generator): the items to be processed as returned by `ls()` + parent (Item, optional): Set this item as parent for the added + items when provided. Defaults to the root of the model. Returns: - node.Node: root node which has children added based on the data + node.Item: root node which has children added based on the data """ self.beginResetModel() @@ -234,8 +233,7 @@ def add_items(self, items, parent=None): family = families[0] # Get the label and icon for the family if in configuration - family_config = loader_lib.get(loader_lib.FAMILY_CONFIG, - family) + family_config = tools_lib.get_family_cached_config(family) family = family_config.get("label", family) family_icon = family_config.get("icon", None) @@ -247,7 +245,7 @@ def add_items(self, items, parent=None): }, sort=[("name", -1)]) # create the group header - group_node = Node() + group_node = Item() group_node["Name"] = "%s_%s: (%s)" % (asset["name"], subset["name"], representation["name"]) @@ -262,7 +260,7 @@ def add_items(self, items, parent=None): self.add_child(group_node, parent=parent) for item in group_items: - item_node = Node() + item_node = Item() item_node.update(item) # store the current version on the item @@ -277,4 +275,4 @@ def add_items(self, items, parent=None): self.endResetModel() - return self._root_node + return self._root_item diff --git a/avalon/tools/sceneinventory/proxy.py b/avalon/tools/sceneinventory/proxy.py index 1752914ea..0be231360 100644 --- a/avalon/tools/sceneinventory/proxy.py +++ b/avalon/tools/sceneinventory/proxy.py @@ -1,6 +1,8 @@ import re + from ...vendor.Qt import QtCore -from .lib import walk_hierarchy + +from . import lib class FilterProxyModel(QtCore.QSortFilterProxyModel): @@ -97,7 +99,7 @@ def outdated(node): return True elif self._hierarchy_view: - for _node in walk_hierarchy(node): + for _node in lib.walk_hierarchy(node): if outdated(_node): return True return False diff --git a/avalon/tools/views.py b/avalon/tools/views.py new file mode 100644 index 000000000..27cc745ed --- /dev/null +++ b/avalon/tools/views.py @@ -0,0 +1,16 @@ +from ..vendor.Qt import QtWidgets, QtCore + + +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + QtWidgets.QTreeView.mousePressEvent(self, event) diff --git a/avalon/tools/widgets.py b/avalon/tools/widgets.py new file mode 100644 index 000000000..081957a80 --- /dev/null +++ b/avalon/tools/widgets.py @@ -0,0 +1,324 @@ +import logging + +from . import lib + +from .models import AssetModel, RecursiveSortFilterProxyModel +from .views import DeselectableTreeView +from ..vendor import qtawesome +from ..vendor.Qt import QtWidgets, QtCore, QtGui + +from .. import style +from .. import io + +log = logging.getLogger(__name__) + + +class AssetWidget(QtWidgets.QWidget): + """A Widget to display a tree of assets with filter + + To list the assets of the active project: + >>> # widget = AssetWidget() + >>> # widget.refresh() + >>> # widget.show() + + """ + + silo_changed = QtCore.Signal(str) # on silo combobox change + assets_refreshed = QtCore.Signal() # on model refresh + selection_changed = QtCore.Signal() # on view selection change + current_changed = QtCore.Signal() # on view current index change + + def __init__(self, silo_creatable=True, parent=None): + super(AssetWidget, self).__init__(parent=parent) + self.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Header + header = QtWidgets.QHBoxLayout() + + silo = SiloTabWidget(silo_creatable=silo_creatable) + + icon = qtawesome.icon("fa.refresh", color=style.colors.light) + refresh = QtWidgets.QPushButton(icon, "") + refresh.setToolTip("Refresh items") + + header.addWidget(silo) + header.addStretch(1) + header.addWidget(refresh) + + # Tree View + model = AssetModel() + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = DeselectableTreeView() + view.setIndentation(15) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setHeaderHidden(True) + view.setModel(proxy) + + filter = QtWidgets.QLineEdit() + filter.textChanged.connect(proxy.setFilterFixedString) + filter.setPlaceholderText("Filter assets..") + + # Layout + layout.addLayout(header) + layout.addWidget(filter) + layout.addWidget(view) + + # Signals/Slots + selection = view.selectionModel() + selection.selectionChanged.connect(self.selection_changed) + selection.currentChanged.connect(self.current_changed) + silo.silo_changed.connect(self._on_silo_changed) + refresh.clicked.connect(self.refresh) + + self.refreshButton = refresh + self.silo = silo + self.model = model + self.proxy = proxy + self.view = view + + def _on_silo_changed(self): + """Callback for silo change""" + + self._refresh_model() + silo = self.get_current_silo() + self.silo_changed.emit(silo) + self.selection_changed.emit() + + def _refresh_model(self): + + silo = self.get_current_silo() + with lib.preserve_expanded_rows(self.view, + column=0, + role=self.model.ObjectIdRole): + with lib.preserve_selection(self.view, + column=0, + role=self.model.ObjectIdRole): + self.model.set_silo(silo) + + self.assets_refreshed.emit() + + def refresh(self): + + silos = _list_project_silos() + self.silo.set_silos(silos) + + self._refresh_model() + + def get_current_silo(self): + """Returns the currently active silo.""" + return self.silo.get_current_silo() + + def get_active_asset(self): + """Return the asset id the current asset.""" + current = self.view.currentIndex() + return current.data(self.model.ObjectIdRole) + + def get_active_index(self): + return self.view.currentIndex() + + def get_selected_assets(self): + """Return the assets' ids that are selected.""" + selection = self.view.selectionModel() + rows = selection.selectedRows() + return [row.data(self.model.ObjectIdRole) for row in rows] + + def set_silo(self, silo): + """Set the active silo tab""" + self.silo.set_current_silo(silo) + + def select_assets(self, assets, expand=True): + """Select assets by name. + + Args: + assets (list): List of asset names + expand (bool): Whether to also expand to the asset in the view + + Returns: + None + + """ + # TODO: Instead of individual selection optimize for many assets + + assert isinstance(assets, + (tuple, list)), "Assets must be list or tuple" + + # Clear selection + selection_model = self.view.selectionModel() + selection_model.clearSelection() + + # Select + mode = selection_model.Select | selection_model.Rows + for index in lib.iter_model_rows(self.proxy, + column=0, + include_root=False): + data = index.data(self.model.ItemRole) + name = data["name"] + if name in assets: + selection_model.select(index, mode) + + if expand: + self.view.expand(index) + + # Set the currently active index + self.view.setCurrentIndex(index) + + +class SiloTabWidget(QtWidgets.QTabBar): + """Silo widget + + Allows to add a silo, with "+" tab. + + Note: + When no silos are present an empty stub silo is added to + use as the "blank" tab to start on, so the + tab becomes + clickable. + + """ + + silo_changed = QtCore.Signal(str) + silo_added = QtCore.Signal(str) + + def __init__(self, silo_creatable=True, parent=None): + super(SiloTabWidget, self).__init__(parent=parent) + self.silo_creatable = silo_creatable + self._previous_tab_index = -1 + self.set_silos([]) + + self.setContentsMargins(0, 0, 0, 0) + self.setFixedHeight(28) + font = QtGui.QFont() + font.setBold(True) + self.setFont(font) + + self.currentChanged.connect(self.on_tab_changed) + + def on_tab_changed(self, index): + + if index == self._previous_tab_index: + return + + # If it's the last tab + num = self.count() + if self.silo_creatable and index == num - 1: + self.on_add_silo() + self.setCurrentIndex(self._previous_tab_index) + return + + silo = self.tabText(index) + self.silo_changed.emit(silo) + + # Store for the next calls + self._previous_tab_index = index + + def clear(self): + """Removes all tabs. + + Implemented similar to `QTabWidget.clear()` + + """ + for i in range(self.count()): + self.removeTab(0) + + def set_silos(self, silos): + + current_silo = self.get_current_silo() + + if not silos: + # Add an emtpy stub tab to start on. + silos = [""] + + # Populate the silos without emitting signals + self.blockSignals(True) + self.clear() + for silo in sorted(silos): + self.addTab(silo) + + if self.silo_creatable: + # Add the "+" tab + self.addTab("+") + + self.set_current_silo(current_silo) + self.blockSignals(False) + + # Assume the current index is "fine" + self._previous_tab_index = self.currentIndex() + + # Only emit a silo changed signal if the new signal + # after refresh is not the same as prior to it (e.g. + # when the silo was removed, or alike.) + if current_silo != self.get_current_silo(): + self.currentChanged.emit(self.currentIndex()) + + def set_current_silo(self, silo): + """Set the active silo by name or index. + + Args: + silo (str or int): The silo name or index. + + """ + + # Already set + if silo == self.get_current_silo(): + return + + # Otherwise change the silo box to the name + for i in range(self.count()): + text = self.tabText(i) + if text == silo: + self.setCurrentIndex(i) + break + + def get_current_silo(self): + index = self.currentIndex() + return self.tabText(index) + + def on_add_silo(self): + silo, state = QtWidgets.QInputDialog.getText(self, + "Silo name", + "Create new silo:") + if not state or not silo: + return + + self.add_silo(silo) + + def get_silos(self): + """Return the currently available silos""" + + # Ignore first tab if empty + # Ignore the last tab because it is the "+" tab + silos = [] + for i in range(self.count() - 1): + label = self.tabText(i) + if i == 0 and not label: + continue + silos.append(label) + return silos + + def add_silo(self, silo): + + # Add the silo + silos = self.get_silos() + silos.append(silo) + silos = list(set(silos)) # ensure unique + self.set_silos(silos) + self.silo_added.emit(silo) + + self.set_current_silo(silo) + + +def _list_project_silos(): + """List the silos from the project's configuration""" + silos = io.distinct("silo") + + if not silos: + project = io.find_one({"type": "project"}) + log.warning("Project '%s' has no active silos", project["name"]) + + return list(sorted(silos)) diff --git a/avalon/tools/workfiles/app.py b/avalon/tools/workfiles/app.py index b713b9ca7..908347336 100644 --- a/avalon/tools/workfiles/app.py +++ b/avalon/tools/workfiles/app.py @@ -4,10 +4,10 @@ import re import shutil - from ...vendor.Qt import QtWidgets, QtCore from ... import style, io, api -from .. import lib as parentlib + +from .. import lib as tools_lib class NameWindow(QtWidgets.QDialog): @@ -487,7 +487,7 @@ def show(root=None, debug=False): api.Session["AVALON_ASSET"] = "Mock" api.Session["AVALON_TASK"] = "Testing" - with parentlib.application(): + with tools_lib.application(): window = Window(root) window.setStyleSheet(style.load_stylesheet()) diff --git a/avalon/version.py b/avalon/version.py index 7d39d1b2c..d978ae216 100644 --- a/avalon/version.py +++ b/avalon/version.py @@ -8,7 +8,7 @@ """ VERSION_MAJOR = 5 -VERSION_MINOR = 3 +VERSION_MINOR = 4 VERSION_PATCH = 0 version = "%s.%s" % (VERSION_MAJOR, VERSION_MINOR)