modified instances view to show parenting hierarchy

This commit is contained in:
Jakub Trllo 2025-07-14 17:14:16 +02:00
parent 6064f095c8
commit 9792be3c84

View file

@ -22,12 +22,15 @@ selection can be enabled disabled using checkbox or keyboard key presses:
...
```
"""
from __future__ import annotations
import collections
import typing
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_objected_colors
from ayon_core.tools.utils import NiceCheckbox
from ayon_core.tools.utils import NiceCheckbox, BaseClickableFrame
from ayon_core.tools.utils.lib import html_escape, checkstate_int_to_enum
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -44,6 +47,9 @@ from ayon_core.tools.publisher.constants import (
from .widgets import AbstractInstanceView
if typing.TYPE_CHECKING:
from ayon_core.tools.publisher.abstract import InstanceItem
class ListItemDelegate(QtWidgets.QStyledItemDelegate):
"""Generic delegate for instance group.
@ -135,8 +141,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
active_checkbox.setVisible(not instance.is_mandatory)
layout = QtWidgets.QHBoxLayout(self)
content_margins = layout.contentsMargins()
layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0)
layout.setContentsMargins(2, 0, 2, 0)
layout.addWidget(product_name_label)
layout.addStretch(1)
layout.addWidget(active_checkbox)
@ -194,6 +199,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
def update_instance(self, instance, context_info):
"""Update instance object."""
# Check product name
self._instance_id = instance.id
label = instance.label
if label != self._instance_label_widget.text():
self._instance_label_widget.setText(html_escape(label))
@ -241,43 +247,33 @@ class ListContextWidget(QtWidgets.QFrame):
self.double_clicked.emit()
class InstanceListGroupWidget(QtWidgets.QFrame):
class InstanceListGroupWidget(BaseClickableFrame):
"""Widget representing group of instances.
Has collapse/expand indicator, label of group and checkbox modifying all
of its children.
Has label of group and checkbox modifying all of its children.
"""
expand_changed = QtCore.Signal(str, bool)
toggle_requested = QtCore.Signal(str, int)
expand_change_requested = QtCore.Signal(str)
def __init__(self, group_name, parent):
super().__init__(parent)
self.setObjectName("InstanceListGroupWidget")
self.group_name = group_name
self._expanded = False
expand_btn = QtWidgets.QToolButton(self)
expand_btn.setObjectName("ArrowBtn")
expand_btn.setArrowType(QtCore.Qt.RightArrow)
expand_btn.setMaximumWidth(14)
name_label = QtWidgets.QLabel(group_name, self)
toggle_checkbox = NiceCheckbox(parent=self)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(5, 0, 2, 0)
layout.addWidget(expand_btn)
layout.setContentsMargins(2, 0, 2, 0)
layout.addWidget(
name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
)
layout.addWidget(toggle_checkbox, 0)
name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground)
expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground)
expand_btn.clicked.connect(self._on_expand_clicked)
toggle_checkbox.stateChanged.connect(self._on_checkbox_change)
self._ignore_state_change = False
@ -285,7 +281,6 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
self._expected_checkstate = None
self.name_label = name_label
self.expand_btn = expand_btn
self.toggle_checkbox = toggle_checkbox
def set_checkstate(self, state):
@ -307,26 +302,15 @@ class InstanceListGroupWidget(QtWidgets.QFrame):
return self.toggle_checkbox.checkState()
def set_active_toggle_enabled(self, enabled):
self.toggle_checkbox.setEnabled(enabled)
def _on_checkbox_change(self, state):
if not self._ignore_state_change:
self.toggle_requested.emit(self.group_name, state)
def _on_expand_clicked(self):
self.expand_changed.emit(self.group_name, not self._expanded)
def set_expanded(self, expanded):
"""Change icon of collapse/expand identifier."""
if self._expanded == expanded:
return
self._expanded = expanded
if expanded:
self.expand_btn.setArrowType(QtCore.Qt.DownArrow)
else:
self.expand_btn.setArrowType(QtCore.Qt.RightArrow)
def set_active_toggle_enabled(self, enabled):
self.toggle_checkbox.setEnabled(enabled)
def _mouse_release_callback(self):
self.expand_change_requested.emit(self.group_name)
class InstanceTreeView(QtWidgets.QTreeView):
@ -339,24 +323,11 @@ class InstanceTreeView(QtWidgets.QTreeView):
self.setObjectName("InstanceListView")
self.setHeaderHidden(True)
self.setIndentation(0)
self.setExpandsOnDoubleClick(False)
self.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
self.viewport().setMouseTracking(True)
self._pressed_group_index = None
def _expand_item(self, index, expand=None):
is_expanded = self.isExpanded(index)
if expand is None:
expand = not is_expanded
if expand != is_expanded:
if expand:
self.expand(index)
else:
self.collapse(index)
def get_selected_instance_ids(self):
"""Ids of selected instances."""
@ -388,53 +359,6 @@ class InstanceTreeView(QtWidgets.QTreeView):
return super().event(event)
def _mouse_press(self, event):
"""Store index of pressed group.
This is to be able to change state of group and process mouse
"double click" as 2x "single click".
"""
if event.button() != QtCore.Qt.LeftButton:
return
pressed_group_index = None
pos_index = self.indexAt(event.pos())
if pos_index.data(IS_GROUP_ROLE):
pressed_group_index = pos_index
self._pressed_group_index = pressed_group_index
def mousePressEvent(self, event):
self._mouse_press(event)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self._mouse_press(event)
super().mouseDoubleClickEvent(event)
def _mouse_release(self, event, pressed_index):
if event.button() != QtCore.Qt.LeftButton:
return False
pos_index = self.indexAt(event.pos())
if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index:
return False
if self.state() == QtWidgets.QTreeView.State.DragSelectingState:
indexes = self.selectionModel().selectedIndexes()
if len(indexes) != 1 or indexes[0] != pos_index:
return False
self._expand_item(pos_index)
return True
def mouseReleaseEvent(self, event):
pressed_index = self._pressed_group_index
self._pressed_group_index = None
result = self._mouse_release(event, pressed_index)
if not result:
super().mouseReleaseEvent(event)
class InstanceListView(AbstractInstanceView):
"""Widget providing abstract methods of AbstractInstanceView for list view.
@ -472,18 +396,19 @@ class InstanceListView(AbstractInstanceView):
instance_view.selectionModel().selectionChanged.connect(
self._on_selection_change
)
instance_view.collapsed.connect(self._on_collapse)
instance_view.expanded.connect(self._on_expand)
instance_view.toggle_requested.connect(self._on_toggle_request)
instance_view.double_clicked.connect(self.double_clicked)
self._group_items = {}
self._group_widgets = {}
self._widgets_by_id = {}
self._widgets_by_id: dict[str, InstanceListItemWidget] = {}
self._items_by_id = {}
self._parent_id_by_id = {}
# Group by instance id for handling of active state
self._group_by_instance_id = {}
self._context_item = None
self._context_widget = None
self._missing_parent_item = None
self._convertor_group_item = None
self._convertor_group_widget = None
@ -496,22 +421,6 @@ class InstanceListView(AbstractInstanceView):
self._active_toggle_enabled = True
def _on_expand(self, index):
self._update_widget_expand_state(index, True)
def _on_collapse(self, index):
self._update_widget_expand_state(index, False)
def _update_widget_expand_state(self, index, expanded):
group_name = index.data(GROUP_ROLE)
if group_name == CONVERTOR_ITEM_GROUP:
group_widget = self._convertor_group_widget
else:
group_widget = self._group_widgets.get(group_name)
if group_widget:
group_widget.set_expanded(expanded)
def _on_toggle_request(self, toggle):
if not self._active_toggle_enabled:
return
@ -583,85 +492,94 @@ class InstanceListView(AbstractInstanceView):
self._update_convertor_items_group()
context_info_by_id = self._controller.get_instances_context_info()
instance_items = self._controller.get_instance_items()
# Prepare instances by their groups
instances_by_group_name = collections.defaultdict(list)
instances_by_parent_id = collections.defaultdict(list)
group_names = set()
for instance in self._controller.get_instance_items():
instance_ids = set()
for instance in instance_items:
instance_ids.add(instance.id)
if instance.parent_instance_id:
instances_by_parent_id[instance.parent_instance_id].append(
instance
)
continue
group_label = instance.group_label
group_names.add(group_label)
instances_by_group_name[group_label].append(instance)
missing_parent_ids = set(instances_by_parent_id) - instance_ids
for instance_id in missing_parent_ids:
for instance in instances_by_parent_id[instance_id]:
group_label = instance.group_label
group_names.add(group_label)
instances_by_group_name[group_label].append(instance)
# Create new groups based on prepared `instances_by_group_name`
if self._make_sure_groups_exists(group_names):
sort_at_the_end = True
# Remove groups that are not available anymore
self._remove_groups_except(group_names)
self._remove_instances_except(instance_items)
# Store which groups should be expanded at the end
expand_groups = set()
expand_to_items = []
widgets_by_id = {}
# Process changes in each group item
# - create new instance, update existing and remove not existing
for group_name, group_item in self._group_items.items():
# Instance items to remove
# - will contain all existing instance ids at the start
# - instance ids may be removed when existing instances are checked
to_remove = set()
# Mapping of existing instances under group item
existing_mapping = {}
# Get group index to be able to get children indexes
group_index = self._instance_model.index(
group_item.row(), group_item.column()
)
# Iterate over children indexes of group item
for idx in range(group_item.rowCount()):
index = self._instance_model.index(idx, 0, group_index)
instance_id = index.data(INSTANCE_ID_ROLE)
# Add all instance into `to_remove` set
to_remove.add(instance_id)
existing_mapping[instance_id] = idx
# Collect all new instances that are not existing under group
# New items
new_items = []
# Tuples of new instance and instance itself
new_items_with_instance = []
new_items = collections.defaultdict(list)
# Tuples of model item and instance itself
items_with_instance = []
# Group activity (should be {-1;0;1} at the end)
# - 0 when all instances are disabled
# - 1 when all instances are enabled
# - -1 when it's mixed
activity = None
for instance in instances_by_group_name[group_name]:
instance_id = instance.id
# Handle group activity
if activity is None:
activity = int(instance.is_active)
elif activity == -1:
pass
elif activity != instance.is_active:
activity = -1
_queue = collections.deque()
_queue.append((instance, group_item, None))
while _queue:
instance, parent_item, parent_id = _queue.popleft()
instance_id = instance.id
# Handle group activity
if activity is None:
activity = int(instance.is_active)
elif activity == -1:
pass
elif activity != instance.is_active:
activity = -1
context_info = context_info_by_id[instance_id]
self._group_by_instance_id[instance_id] = group_name
self._group_by_instance_id[instance_id] = group_name
# Remove instance id from `to_remove` if already exists and
# trigger update of widget
if instance_id in to_remove:
to_remove.remove(instance_id)
widget = self._widgets_by_id[instance_id]
widget.update_instance(instance, context_info)
continue
# Create new item and store it as new
item = self._items_by_id.get(instance_id)
if item is None:
item = QtGui.QStandardItem()
item.setData(instance_id, INSTANCE_ID_ROLE)
self._items_by_id[instance_id] = item
new_items[parent_id].append(item)
elif parent_id != self._parent_id_by_id.get(instance_id):
new_items[parent_id].append(item)
# Create new item and store it as new
item = QtGui.QStandardItem()
item.setData(instance.product_name, SORT_VALUE_ROLE)
item.setData(instance.product_name, GROUP_ROLE)
item.setData(instance_id, INSTANCE_ID_ROLE)
new_items.append(item)
new_items_with_instance.append((item, instance))
self._parent_id_by_id[instance_id] = parent_id
children = instances_by_parent_id.pop(instance_id, [])
items_with_instance.append(
(item, instance, bool(children))
)
item.setData(instance.product_name, SORT_VALUE_ROLE)
item.setData(instance.product_name, GROUP_ROLE)
for child in children:
_queue.append((child, item, instance_id))
# Set checkstate of group checkbox
state = QtCore.Qt.PartiallyChecked
@ -670,23 +588,9 @@ class InstanceListView(AbstractInstanceView):
elif activity == 1:
state = QtCore.Qt.Checked
widget = self._group_widgets[group_name]
widget.set_checkstate(state)
# Remove items that were not found
idx_to_remove = []
for instance_id in to_remove:
idx_to_remove.append(existing_mapping[instance_id])
# Remove them in reverse order to prevent row index changes
for idx in reversed(sorted(idx_to_remove)):
group_item.removeRows(idx, 1)
# Cleanup instance related widgets
for instance_id in to_remove:
self._group_by_instance_id.pop(instance_id)
widget = self._widgets_by_id.pop(instance_id)
widget.deleteLater()
if group_name is not None:
widget = self._group_widgets[group_name]
widget.set_checkstate(state)
# Process new instance items and add them to model and create
# their widgets
@ -695,40 +599,76 @@ class InstanceListView(AbstractInstanceView):
sort_at_the_end = True
# Add items under group item
group_item.appendRows(new_items)
for parent_id, items in new_items.items():
if parent_id is None:
parent_item = group_item
else:
parent_item = self._items_by_id[parent_id]
for item, instance in new_items_with_instance:
context_info = context_info_by_id[instance.id]
if not context_info.is_valid:
expand_groups.add(group_name)
item_index = self._instance_model.index(
item.row(),
item.column(),
group_index
)
proxy_index = self._proxy_model.mapFromSource(item_index)
parent_item.appendRows(items)
for item, instance, has_children in items_with_instance:
context_info = context_info_by_id[instance.id]
# TODO expand all parents
if not context_info.is_valid:
expand_groups.add(group_name)
expand_to_items.append(item)
item_index = self._instance_model.indexFromItem(item)
proxy_index = self._proxy_model.mapFromSource(item_index)
widget = self._instance_view.indexWidget(proxy_index)
if isinstance(widget, InstanceListItemWidget):
widget.update_instance(instance, context_info)
else:
widget = InstanceListItemWidget(
instance, context_info, self._instance_view
)
widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
widget.active_changed.connect(self._on_active_changed)
widget.double_clicked.connect(self.double_clicked)
self._instance_view.setIndexWidget(proxy_index, widget)
self._widgets_by_id[instance.id] = widget
widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
# Trigger sort at the end of refresh
if sort_at_the_end:
self._proxy_model.sort(0)
widgets_by_id[instance.id] = widget
self._widgets_by_id.pop(instance.id, None)
# Expand groups marked for expanding
for group_name in expand_groups:
group_item = self._group_items[group_name]
proxy_index = self._proxy_model.mapFromSource(group_item.index())
for widget in self._widgets_by_id.values():
widget.setVisible(False)
widget.deleteLater()
self._widgets_by_id = widgets_by_id
# Expand items marked for expanding
items_to_expand = [
self._group_items[group_name]
for group_name in expand_groups
]
_marked_ids = set()
for item in expand_to_items:
parent = item.parent()
_items = []
while True:
# Parent is not set or is group (groups are separate)
if parent is None or parent.data(IS_GROUP_ROLE):
break
instance_id = parent.data(INSTANCE_ID_ROLE)
# Parent was already marked for expanding
if instance_id in _marked_ids:
break
_marked_ids.add(instance_id)
_items.append(parent)
parent = parent.parent()
items_to_expand.extend(reversed(_items))
for item in items_to_expand:
proxy_index = self._proxy_model.mapFromSource(item.index())
self._instance_view.expand(proxy_index)
# Trigger sort at the end of refresh
if sort_at_the_end:
self._proxy_model.sort(0)
def _make_sure_context_item_exists(self):
if self._context_item is not None:
return False
@ -761,7 +701,7 @@ class InstanceListView(AbstractInstanceView):
root_item = self._instance_model.invisibleRootItem()
if not convertor_items_by_id:
root_item.removeRow(group_item.row())
root_item.takeRow(group_item.row())
self._convertor_group_widget.deleteLater()
self._convertor_group_widget = None
self._convertor_items_by_id = {}
@ -785,9 +725,7 @@ class InstanceListView(AbstractInstanceView):
CONVERTOR_ITEM_GROUP, self._instance_view
)
widget.toggle_checkbox.setVisible(False)
widget.expand_changed.connect(
self._on_convertor_group_expand_request
)
self._instance_view.setIndexWidget(proxy_index, widget)
self._convertor_group_item = group_item
@ -798,7 +736,7 @@ class InstanceListView(AbstractInstanceView):
child_identifier = child_item.data(CONVERTER_IDENTIFIER_ROLE)
if child_identifier not in convertor_items_by_id:
self._convertor_items_by_id.pop(child_identifier, None)
group_item.removeRows(row, 1)
group_item.takeRow(row)
new_items = []
for identifier, convertor_item in convertor_items_by_id.items():
@ -853,8 +791,10 @@ class InstanceListView(AbstractInstanceView):
widget.set_active_toggle_enabled(
self._active_toggle_enabled
)
widget.expand_changed.connect(self._on_group_expand_request)
widget.toggle_requested.connect(self._on_group_toggle_request)
widget.expand_change_requested.connect(
self._on_expand_toggle_request
)
self._group_widgets[group_name] = widget
self._instance_view.setIndexWidget(proxy_index, widget)
@ -868,10 +808,84 @@ class InstanceListView(AbstractInstanceView):
continue
group_item = self._group_items.pop(group_name)
root_item.removeRow(group_item.row())
root_item.takeRow(group_item.row())
widget = self._group_widgets.pop(group_name)
widget.setVisible(False)
widget.deleteLater()
def _remove_instances_except(self, instance_items: list[InstanceItem]):
parent_id_by_id = {
item.id: item.parent_instance_id
for item in instance_items
}
instance_ids = set(parent_id_by_id)
all_removed_ids = set(self._items_by_id) - instance_ids
queue = collections.deque()
for group_item in self._group_items.values():
queue.append((group_item, None))
while queue:
parent_item, parent_id = queue.popleft()
children = [
parent_item.child(row)
for row in range(parent_item.rowCount())
]
for child in children:
instance_id = child.data(INSTANCE_ID_ROLE)
if instance_id not in parent_id_by_id:
parent_item.takeRow(child.row())
elif parent_id != parent_id_by_id[instance_id]:
parent_item.takeRow(child.row())
queue.append((child, instance_id))
for instance_id in all_removed_ids:
self._items_by_id.pop(instance_id)
self._group_by_instance_id.pop(instance_id)
self._parent_id_by_id.pop(instance_id)
widget = self._widgets_by_id.pop(instance_id, None)
if widget is not None:
widget.setVisible(False)
widget.deleteLater()
def _add_missing_parent_item(self):
label = "! Orphaned instances !"
if self._missing_parent_item is None:
item = QtGui.QStandardItem()
item.setData(label, GROUP_ROLE)
item.setData("_", SORT_VALUE_ROLE)
item.setData(True, IS_GROUP_ROLE)
item.setFlags(QtCore.Qt.ItemIsEnabled)
self._missing_parent_item = item
if self._missing_parent_item.parent() is None:
root_item = self._instance_model.invisibleRootItem()
root_item.appendRow(self._missing_parent_item)
index = self._missing_parent_item.index()
proxy_index = self._proxy_model.mapFromSource(index)
widget = InstanceListGroupWidget(label, self._instance_view)
widget.toggle_checkbox.setVisible(False)
self._instance_view.setIndexWidget(proxy_index, widget)
return self._missing_parent_item
def _remove_missing_parent_item(self):
if self._missing_parent_item is None:
return
row = self._missing_parent_item.row()
if row < 0:
return
parent = self._missing_parent_item.parent()
if parent is None:
parent = self._instance_model.invisibleRootItem()
index = self._missing_parent_item.index()
proxy_index = self._proxy_model.mapFromSource(index)
widget = self._instance_view.indexWidget(proxy_index)
if widget is not None:
widget.setVisible(False)
widget.deleteLater()
parent.takeRow(self._missing_parent_item.row())
def refresh_instance_states(self, instance_ids=None):
"""Trigger update of all instances."""
if instance_ids is not None:
@ -925,26 +939,13 @@ class InstanceListView(AbstractInstanceView):
def _on_selection_change(self, *_args):
self.selection_changed.emit()
def _on_group_expand_request(self, group_name, expanded):
def _on_expand_toggle_request(self, group_name):
group_item = self._group_items.get(group_name)
if not group_item:
return
group_index = self._instance_model.index(
group_item.row(), group_item.column()
)
proxy_index = self._proxy_model.mapFromSource(group_index)
self._instance_view.setExpanded(proxy_index, expanded)
def _on_convertor_group_expand_request(self, _, expanded):
group_item = self._convertor_group_item
if not group_item:
return
group_index = self._instance_model.index(
group_item.row(), group_item.column()
)
proxy_index = self._proxy_model.mapFromSource(group_index)
self._instance_view.setExpanded(proxy_index, expanded)
proxy_index = self._proxy_model.mapFromSource(group_item.index())
new_state = not self._instance_view.isExpanded(proxy_index)
self._instance_view.setExpanded(proxy_index, new_state)
def _on_group_toggle_request(self, group_name, state):
state = checkstate_int_to_enum(state)
@ -962,24 +963,33 @@ class InstanceListView(AbstractInstanceView):
active_by_id = {}
all_changed = True
for row in range(group_item.rowCount()):
item = group_item.child(row)
instance_id = item.data(INSTANCE_ID_ROLE)
widget = self._widgets_by_id.get(instance_id)
if widget is None:
continue
if widget.is_checkbox_enabled():
active_by_id[instance_id] = active
else:
all_changed = False
items_to_expand = [group_item]
_queue = collections.deque()
_queue.append(group_item)
while _queue:
item = _queue.popleft()
for row in range(item.rowCount()):
child = item.child(row)
instance_id = child.data(INSTANCE_ID_ROLE)
if child.hasChildren():
items_to_expand.append(child)
_queue.append(child)
widget = self._widgets_by_id.get(instance_id)
if widget is None:
continue
if widget.is_checkbox_enabled():
active_by_id[instance_id] = active
else:
all_changed = False
self._controller.set_instances_active_state(active_by_id)
self._change_active_instances(active_by_id, active)
proxy_index = self._proxy_model.mapFromSource(group_item.index())
if not self._instance_view.isExpanded(proxy_index):
self._instance_view.expand(proxy_index)
for item in items_to_expand:
proxy_index = self._proxy_model.mapFromSource(item.index())
if not self._instance_view.isExpanded(proxy_index):
self._instance_view.expand(proxy_index)
if not all_changed:
# If not all instances were changed, update group checkstate