multiselect combobox enhancements

This commit is contained in:
Jakub Trllo 2025-02-18 14:36:21 +01:00
parent 1444fe7e93
commit ac93b5a34b
2 changed files with 381 additions and 234 deletions

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import typing
from typing import List, Tuple, Optional, Iterable, Any
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils.lib import (
checkstate_int_to_enum,
checkstate_enum_to_int,
@ -11,14 +14,269 @@ from ayon_core.tools.utils.constants import (
UNCHECKED_INT,
ITEM_IS_USER_TRISTATE,
)
if typing.TYPE_CHECKING:
from ayon_core.tools.loader.abstract import FrontendLoaderController
VALUE_ITEM_TYPE = 0
STANDARD_ITEM_TYPE = 1
SEPARATOR_ITEM_TYPE = 2
VALUE_ITEM_SUBTYPE = 0
SELECT_ALL_SUBTYPE = 1
DESELECT_ALL_SUBTYPE = 2
SWAP_STATE_SUBTYPE = 3
class BaseQtModel(QtGui.QStandardItemModel):
_empty_icon = None
def __init__(
self,
item_type_role: int,
item_subtype_role: int,
empty_values_label: str,
controller: FrontendLoaderController,
):
self._item_type_role = item_type_role
self._item_subtype_role = item_subtype_role
self._empty_values_label = empty_values_label
self._controller = controller
self._last_project = None
self._select_project_item = None
self._empty_values_item = None
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
super().__init__()
self.refresh(None)
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
raise NotImplementedError(
"'_get_standard_items' is not implemented"
f" for {self.__class__}"
)
def _clear_standard_items(self):
raise NotImplementedError(
"'_clear_standard_items' is not implemented"
f" for {self.__class__}"
)
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
raise NotImplementedError(
"'_prepare_new_value_items' is not implemented"
f" for {self.__class__}"
)
def refresh(self, project_name: Optional[str]):
# New project was selected
project_changed = False
if project_name != self._last_project:
self._last_project = project_name
project_changed = True
if project_name is None:
self._add_select_project_item()
return
value_items, items_to_remove = self._prepare_new_value_items(
project_name, project_changed
)
if not value_items:
self._add_empty_values_item()
return
self._remove_empty_items()
root_item = self.invisibleRootItem()
for row_idx, value_item in enumerate(value_items):
if value_item.row() == row_idx:
continue
if value_item.row() >= 0:
root_item.takeRow(value_item.row())
root_item.insertRow(row_idx, value_item)
for item in items_to_remove:
root_item.removeRow(item.row())
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_subtype = index.data(self._item_subtype_role)
if item_subtype == SELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_subtype == DESELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_subtype == SWAP_STATE_SUBTYPE:
for item in self._get_standard_items():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def _init_default_items(self):
if self._empty_values_item is not None:
return
empty_values_item = QtGui.QStandardItem(self._empty_values_label)
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_values_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, self._item_type_role)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_values_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_SUBTYPE),
(deselect_all_item, DESELECT_ALL_SUBTYPE),
(swap_states_item, SWAP_STATE_SUBTYPE),
):
item.setData(item_type, self._item_subtype_role)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_values_item = empty_values_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_values_item(self):
self._init_default_items()
return self._empty_values_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_values_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_values_item(self):
item = self._get_empty_values_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._clear_standard_items()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
_empty_icon = None
_checked_value = checkstate_enum_to_int(QtCore.Qt.Checked)
_checked_bg_color = QtGui.QColor("#2C3B4C")
@ -38,6 +296,14 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
self._icon_role = icon_role
self._item_type_role = item_type_role
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def paint(self, painter, option, index):
item_type = None
if self._item_type_role is not None:
@ -70,6 +336,9 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
icon = self._get_index_icon(index)
if icon is None or icon.isNull():
icon = self._get_empty_icon()
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
# Disable visible check indicator
@ -241,6 +510,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.Key_Home,
QtCore.Qt.Key_End,
}
_top_bottom_margins = 1
_top_bottom_padding = 2
_left_right_padding = 3
_item_bg_color = QtGui.QColor("#31424e")
def __init__(
self,
@ -433,14 +706,14 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
idxs = self._get_checked_idx()
# draw the icon and text
draw_text = True
draw_items = False
combotext = None
if self._custom_text is not None:
combotext = self._custom_text
elif not idxs:
combotext = self._placeholder_text
else:
draw_text = False
draw_items = True
content_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
@ -448,7 +721,9 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtWidgets.QStyle.SC_ComboBoxEditField
).adjusted(1, 0, -1, 0)
if draw_text:
if draw_items:
self._paint_items(painter, idxs, content_field_rect)
else:
color = option.palette.color(QtGui.QPalette.Text)
color.setAlpha(67)
pen = painter.pen()
@ -459,15 +734,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
combotext
)
else:
self._paint_items(painter, idxs, content_field_rect)
painter.end()
def _paint_items(self, painter, indexes, content_rect):
origin_rect = QtCore.QRect(content_rect)
metrics = self.fontMetrics()
model = self.model()
available_width = content_rect.width()
total_used_width = 0
@ -482,31 +754,80 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
continue
icon = index.data(self._icon_role)
# TODO handle this case
if icon is None or icon.isNull():
continue
text = index.data(self._text_role)
valid_icon = icon is not None and not icon.isNull()
if valid_icon:
sizes = icon.availableSizes()
if sizes:
valid_icon = any(size.width() > 1 for size in sizes)
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
icon_rect.setWidth(metrics.height())
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
if total_used_width > 0:
total_used_width += spacing
total_used_width += icon_rect.width()
if total_used_width > available_width:
break
if valid_icon:
metrics = self.fontMetrics()
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
used_width = metrics.height()
if total_used_width > 0:
total_used_width += spacing
total_used_width += used_width
if total_used_width > available_width:
break
icon_rect.setWidth(used_width)
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
elif text:
bg_height = (
content_rect.height()
- (2 * self._top_bottom_margins)
)
font_height = bg_height - (2 * self._top_bottom_padding)
bg_top_y = content_rect.y() + self._top_bottom_margins
font = self.font()
font.setPixelSize(font_height)
metrics = QtGui.QFontMetrics(font)
painter.setFont(font)
label_rect = metrics.boundingRect(text)
bg_width = label_rect.width() + (2 * self._left_right_padding)
if total_used_width > 0:
total_used_width += spacing
total_used_width += bg_width
if total_used_width > available_width:
break
bg_rect = QtCore.QRectF(label_rect)
bg_rect.moveTop(bg_top_y)
bg_rect.moveLeft(content_rect.left())
bg_rect.setWidth(bg_width)
bg_rect.setHeight(bg_height)
label_rect.moveTop(bg_top_y)
label_rect.moveLeft(
content_rect.left() + self._left_right_padding
)
path = QtGui.QPainterPath()
path.addRoundedRect(bg_rect, 5, 5)
painter.fillPath(path, self._item_bg_color)
painter.drawText(label_rect, QtCore.Qt.AlignCenter, text)
content_rect.setLeft(bg_rect.right() + spacing)
painter.restore()

View file

@ -1,4 +1,4 @@
from typing import List, Dict
from __future__ import annotations
from qtpy import QtCore, QtGui
@ -7,7 +7,7 @@ from ayon_core.tools.common_models import StatusItem
from ._multicombobox import (
CustomPaintMultiselectComboBox,
STANDARD_ITEM_TYPE,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
@ -24,62 +24,43 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6
class StatusesQtModel(QtGui.QStandardItemModel):
class StatusesQtModel(BaseQtModel):
def __init__(self, controller):
self._controller = controller
self._items_by_name: Dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {}
self._last_project = None
self._items_by_name: dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {}
super().__init__(
ITEM_TYPE_ROLE,
ITEM_SUBTYPE_ROLE,
"No statuses...",
controller,
)
self._select_project_item = None
self._empty_statuses_item = None
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
def _clear_standard_items(self):
self._items_by_name.clear()
super().__init__()
self.refresh(None)
def get_placeholder_text(self):
return self._placeholder
def refresh(self, project_name):
# New project was selected
# status filter is reset to show all statuses
uncheck_all = False
if project_name != self._last_project:
self._last_project = project_name
uncheck_all = True
if project_name is None:
self._add_select_project_item()
return
status_items: List[StatusItem] = (
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
):
status_items: list[StatusItem] = (
self._controller.get_project_status_items(
project_name, sender=STATUSES_FILTER_SENDER
)
)
items = []
items_to_remove = []
if not status_items:
self._add_empty_statuses_item()
return
return items, items_to_remove
self._remove_empty_items()
items_to_remove = set(self._items_by_name)
root_item = self.invisibleRootItem()
names_to_remove = set(self._items_by_name)
for row_idx, status_item in enumerate(status_items):
name = status_item.name
if name in self._items_by_name:
is_new = False
item = self._items_by_name[name]
if uncheck_all:
item.setCheckState(QtCore.Qt.Unchecked)
items_to_remove.discard(name)
names_to_remove.discard(name)
else:
is_new = True
item = QtGui.QStandardItem()
item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE)
item.setCheckState(QtCore.Qt.Unchecked)
@ -100,36 +81,14 @@ class StatusesQtModel(QtGui.QStandardItemModel):
if item.data(role) != value:
item.setData(value, role)
if is_new:
root_item.insertRow(row_idx, item)
if project_changed:
item.setCheckState(QtCore.Qt.Unchecked)
items.append(item)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
for name in names_to_remove:
items_to_remove.append(self._items_by_name.pop(name))
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_type = index.data(ITEM_SUBTYPE_ROLE)
if item_type == SELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_type == DESELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_type == SWAP_STATE_TYPE:
for item in self._items_by_name.values():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
return items, items_to_remove
def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon:
name = status_item.name
@ -147,139 +106,6 @@ class StatusesQtModel(QtGui.QStandardItemModel):
self._icons_by_name_n_color[unique_id] = icon
return icon
def _init_default_items(self):
if self._empty_statuses_item is not None:
return
empty_statuses_item = QtGui.QStandardItem("No statuses...")
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_statuses_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_statuses_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_TYPE),
(deselect_all_item, DESELECT_ALL_TYPE),
(swap_states_item, SWAP_STATE_TYPE),
):
item.setData(item_type, ITEM_SUBTYPE_ROLE)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_statuses_item = empty_statuses_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_statuses_item(self):
self._init_default_items()
return self._empty_statuses_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_statuses_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_statuses_item(self):
item = self._get_empty_statuses_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._items_by_name.clear()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class StatusesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):