mirror of
https://github.com/ynput/ayon-core.git
synced 2026-01-01 16:34:53 +01:00
565 lines
19 KiB
Python
565 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
try:
|
|
import commonmark
|
|
except Exception:
|
|
commonmark = None
|
|
|
|
from Qt import QtWidgets, QtCore, QtGui
|
|
|
|
from openpype.tools.utils import BaseClickableFrame
|
|
from .widgets import (
|
|
IconValuePixmapLabel
|
|
)
|
|
from ..constants import (
|
|
INSTANCE_ID_ROLE
|
|
)
|
|
|
|
|
|
class ValidationErrorInstanceList(QtWidgets.QListView):
|
|
"""List of publish instances that caused a validation error.
|
|
|
|
Instances are collected per plugin's validation error title.
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
super(ValidationErrorInstanceList, self).__init__(*args, **kwargs)
|
|
|
|
self.setObjectName("ValidationErrorInstanceList")
|
|
|
|
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
|
self.setSelectionMode(QtWidgets.QListView.ExtendedSelection)
|
|
|
|
def minimumSizeHint(self):
|
|
return self.sizeHint()
|
|
|
|
def sizeHint(self):
|
|
result = super(ValidationErrorInstanceList, self).sizeHint()
|
|
row_count = self.model().rowCount()
|
|
height = 0
|
|
if row_count > 0:
|
|
height = self.sizeHintForRow(0) * row_count
|
|
result.setHeight(height)
|
|
return result
|
|
|
|
|
|
class ValidationErrorTitleWidget(QtWidgets.QWidget):
|
|
"""Title of validation error.
|
|
|
|
Widget is used as radio button so requires clickable functionality and
|
|
changing style on selection/deselection.
|
|
|
|
Has toggle button to show/hide instances on which validation error happened
|
|
if there is a list (Valdation error may happen on context).
|
|
"""
|
|
selected = QtCore.Signal(int)
|
|
instance_changed = QtCore.Signal(int)
|
|
|
|
def __init__(self, index, error_info, parent):
|
|
super(ValidationErrorTitleWidget, self).__init__(parent)
|
|
|
|
self._index = index
|
|
self._error_info = error_info
|
|
self._selected = False
|
|
|
|
title_frame = BaseClickableFrame(self)
|
|
title_frame.setObjectName("ValidationErrorTitleFrame")
|
|
title_frame._mouse_release_callback = self._mouse_release_callback
|
|
|
|
toggle_instance_btn = QtWidgets.QToolButton(title_frame)
|
|
toggle_instance_btn.setObjectName("ArrowBtn")
|
|
toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
|
|
toggle_instance_btn.setMaximumWidth(14)
|
|
|
|
label_widget = QtWidgets.QLabel(error_info["title"], title_frame)
|
|
|
|
title_frame_layout = QtWidgets.QHBoxLayout(title_frame)
|
|
title_frame_layout.addWidget(toggle_instance_btn)
|
|
title_frame_layout.addWidget(label_widget)
|
|
|
|
instances_model = QtGui.QStandardItemModel()
|
|
error_info = error_info["error_info"]
|
|
|
|
help_text_by_instance_id = {}
|
|
context_validation = False
|
|
if (
|
|
not error_info
|
|
or (len(error_info) == 1 and error_info[0][0] is None)
|
|
):
|
|
context_validation = True
|
|
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
|
|
description = self._prepare_description(error_info[0][1])
|
|
help_text_by_instance_id[None] = description
|
|
else:
|
|
items = []
|
|
for instance, exception in error_info:
|
|
label = instance.data.get("label") or instance.data.get("name")
|
|
item = QtGui.QStandardItem(label)
|
|
item.setFlags(
|
|
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
|
)
|
|
item.setData(label, QtCore.Qt.ToolTipRole)
|
|
item.setData(instance.id, INSTANCE_ID_ROLE)
|
|
items.append(item)
|
|
description = self._prepare_description(exception)
|
|
help_text_by_instance_id[instance.id] = description
|
|
|
|
instances_model.invisibleRootItem().appendRows(items)
|
|
|
|
instances_view = ValidationErrorInstanceList(self)
|
|
instances_view.setModel(instances_model)
|
|
instances_view.setVisible(False)
|
|
|
|
self.setLayoutDirection(QtCore.Qt.LeftToRight)
|
|
|
|
view_layout = QtWidgets.QHBoxLayout()
|
|
view_layout.setContentsMargins(0, 0, 0, 0)
|
|
view_layout.setSpacing(0)
|
|
view_layout.addSpacing(14)
|
|
view_layout.addWidget(instances_view)
|
|
|
|
layout = QtWidgets.QVBoxLayout(self)
|
|
layout.setSpacing(0)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.addWidget(title_frame)
|
|
layout.addLayout(view_layout)
|
|
|
|
if not context_validation:
|
|
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
|
|
|
|
instances_view.selectionModel().selectionChanged.connect(
|
|
self._on_seleciton_change
|
|
)
|
|
|
|
self._title_frame = title_frame
|
|
|
|
self._toggle_instance_btn = toggle_instance_btn
|
|
|
|
self._view_layout = view_layout
|
|
|
|
self._instances_model = instances_model
|
|
self._instances_view = instances_view
|
|
|
|
self._context_validation = context_validation
|
|
self._help_text_by_instance_id = help_text_by_instance_id
|
|
|
|
def sizeHint(self):
|
|
result = super().sizeHint()
|
|
expected_width = 0
|
|
for idx in range(self._view_layout.count()):
|
|
expected_width += self._view_layout.itemAt(idx).sizeHint().width()
|
|
|
|
if expected_width < 200:
|
|
expected_width = 200
|
|
|
|
if result.width() < expected_width:
|
|
result.setWidth(expected_width)
|
|
return result
|
|
|
|
def minimumSizeHint(self):
|
|
return self.sizeHint()
|
|
|
|
def _prepare_description(self, exception):
|
|
dsc = exception.description
|
|
detail = exception.detail
|
|
if detail:
|
|
dsc += "<br/><br/>{}".format(detail)
|
|
|
|
description = dsc
|
|
if commonmark:
|
|
description = commonmark.commonmark(dsc)
|
|
return description
|
|
|
|
def _mouse_release_callback(self):
|
|
"""Mark this widget as selected on click."""
|
|
self.set_selected(True)
|
|
|
|
def current_desctiption_text(self):
|
|
if self._context_validation:
|
|
return self._help_text_by_instance_id[None]
|
|
index = self._instances_view.currentIndex()
|
|
# TODO make sure instance is selected
|
|
if not index.isValid():
|
|
index = self._instances_model.index(0, 0)
|
|
|
|
indence_id = index.data(INSTANCE_ID_ROLE)
|
|
return self._help_text_by_instance_id[indence_id]
|
|
|
|
@property
|
|
def is_selected(self):
|
|
"""Is widget marked a selected"""
|
|
return self._selected
|
|
|
|
@property
|
|
def index(self):
|
|
"""Widget's index set by parent."""
|
|
return self._index
|
|
|
|
def set_index(self, index):
|
|
"""Set index of widget (called by parent)."""
|
|
self._index = index
|
|
|
|
def _change_style_property(self, selected):
|
|
"""Change style of widget based on selection."""
|
|
value = "1" if selected else ""
|
|
self._title_frame.setProperty("selected", value)
|
|
self._title_frame.style().polish(self._title_frame)
|
|
|
|
def set_selected(self, selected=None):
|
|
"""Change selected state of widget."""
|
|
if selected is None:
|
|
selected = not self._selected
|
|
|
|
elif selected == self._selected:
|
|
return
|
|
|
|
self._selected = selected
|
|
self._change_style_property(selected)
|
|
if selected:
|
|
self.selected.emit(self._index)
|
|
|
|
def _on_toggle_btn_click(self):
|
|
"""Show/hide instances list."""
|
|
new_visible = not self._instances_view.isVisible()
|
|
self._instances_view.setVisible(new_visible)
|
|
if new_visible:
|
|
self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow)
|
|
else:
|
|
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
|
|
|
|
def _on_seleciton_change(self):
|
|
self.instance_changed.emit(self._index)
|
|
|
|
|
|
class ActionButton(BaseClickableFrame):
|
|
"""Plugin's action callback button.
|
|
|
|
Action may have label or icon or both.
|
|
"""
|
|
action_clicked = QtCore.Signal(str)
|
|
|
|
def __init__(self, action, parent):
|
|
super(ActionButton, self).__init__(parent)
|
|
|
|
self.setObjectName("ValidationActionButton")
|
|
|
|
self.action = action
|
|
|
|
action_label = action.label or action.__name__
|
|
action_icon = getattr(action, "icon", None)
|
|
label_widget = QtWidgets.QLabel(action_label, self)
|
|
icon_label = None
|
|
if action_icon:
|
|
icon_label = IconValuePixmapLabel(action_icon, self)
|
|
|
|
layout = QtWidgets.QHBoxLayout(self)
|
|
layout.setContentsMargins(5, 0, 5, 0)
|
|
layout.addWidget(label_widget, 1)
|
|
if icon_label:
|
|
layout.addWidget(icon_label, 0)
|
|
|
|
self.setSizePolicy(
|
|
QtWidgets.QSizePolicy.Minimum,
|
|
self.sizePolicy().verticalPolicy()
|
|
)
|
|
|
|
def _mouse_release_callback(self):
|
|
self.action_clicked.emit(self.action.id)
|
|
|
|
|
|
class ValidateActionsWidget(QtWidgets.QFrame):
|
|
"""Wrapper widget for plugin actions.
|
|
|
|
Change actions based on selected validation error.
|
|
"""
|
|
def __init__(self, controller, parent):
|
|
super(ValidateActionsWidget, self).__init__(parent)
|
|
|
|
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
|
|
|
content_widget = QtWidgets.QWidget(self)
|
|
content_layout = QtWidgets.QVBoxLayout(content_widget)
|
|
|
|
layout = QtWidgets.QHBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.addWidget(content_widget)
|
|
|
|
self.controller = controller
|
|
self._content_widget = content_widget
|
|
self._content_layout = content_layout
|
|
self._plugin = None
|
|
self._actions_mapping = {}
|
|
|
|
def clear(self):
|
|
"""Remove actions from widget."""
|
|
while self._content_layout.count():
|
|
item = self._content_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.setVisible(False)
|
|
widget.deleteLater()
|
|
self._actions_mapping = {}
|
|
|
|
def set_plugin(self, plugin):
|
|
"""Set selected plugin and show it's actions.
|
|
|
|
Clears current actions from widget and recreate them from the plugin.
|
|
"""
|
|
self.clear()
|
|
self._plugin = plugin
|
|
if not plugin:
|
|
self.setVisible(False)
|
|
return
|
|
|
|
actions = getattr(plugin, "actions", [])
|
|
for action in actions:
|
|
if not action.active:
|
|
continue
|
|
|
|
if action.on not in ("failed", "all"):
|
|
continue
|
|
|
|
self._actions_mapping[action.id] = action
|
|
|
|
action_btn = ActionButton(action, self._content_widget)
|
|
action_btn.action_clicked.connect(self._on_action_click)
|
|
self._content_layout.addWidget(action_btn)
|
|
|
|
if self._content_layout.count() > 0:
|
|
self.setVisible(True)
|
|
self._content_layout.addStretch(1)
|
|
else:
|
|
self.setVisible(False)
|
|
|
|
def _on_action_click(self, action_id):
|
|
action = self._actions_mapping[action_id]
|
|
self.controller.run_action(self._plugin, action)
|
|
|
|
|
|
class VerticallScrollArea(QtWidgets.QScrollArea):
|
|
"""Scroll area for validation error titles.
|
|
|
|
The biggest difference is that the scroll area has scroll bar on left side
|
|
and resize of content will also resize scrollarea itself.
|
|
|
|
Resize if deferred by 100ms because at the moment of resize are not yet
|
|
propagated sizes and visibility of scroll bars.
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
super(VerticallScrollArea, self).__init__(*args, **kwargs)
|
|
|
|
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
|
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
|
|
self.setLayoutDirection(QtCore.Qt.RightToLeft)
|
|
|
|
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
|
# Background of scrollbar will be transparent
|
|
scrollbar_bg = self.verticalScrollBar().parent()
|
|
if scrollbar_bg:
|
|
scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
|
self.setViewportMargins(0, 0, 0, 0)
|
|
|
|
self.verticalScrollBar().installEventFilter(self)
|
|
|
|
# Timer with 100ms offset after changing size
|
|
size_changed_timer = QtCore.QTimer()
|
|
size_changed_timer.setInterval(100)
|
|
size_changed_timer.setSingleShot(True)
|
|
|
|
size_changed_timer.timeout.connect(self._on_timer_timeout)
|
|
self._size_changed_timer = size_changed_timer
|
|
|
|
def setVerticalScrollBar(self, widget):
|
|
old_widget = self.verticalScrollBar()
|
|
if old_widget:
|
|
old_widget.removeEventFilter(self)
|
|
|
|
super(VerticallScrollArea, self).setVerticalScrollBar(widget)
|
|
if widget:
|
|
widget.installEventFilter(self)
|
|
|
|
def setWidget(self, widget):
|
|
old_widget = self.widget()
|
|
if old_widget:
|
|
old_widget.removeEventFilter(self)
|
|
|
|
super(VerticallScrollArea, self).setWidget(widget)
|
|
if widget:
|
|
widget.installEventFilter(self)
|
|
|
|
def _on_timer_timeout(self):
|
|
width = self.widget().width()
|
|
if self.verticalScrollBar().isVisible():
|
|
width += self.verticalScrollBar().width()
|
|
self.setMinimumWidth(width)
|
|
|
|
def eventFilter(self, obj, event):
|
|
if (
|
|
event.type() == QtCore.QEvent.Resize
|
|
and (obj is self.widget() or obj is self.verticalScrollBar())
|
|
):
|
|
self._size_changed_timer.start()
|
|
return super(VerticallScrollArea, self).eventFilter(obj, event)
|
|
|
|
|
|
class ValidationsWidget(QtWidgets.QWidget):
|
|
"""Widgets showing validation error.
|
|
|
|
This widget is shown if validation error/s happened during validation part.
|
|
|
|
Shows validation error titles with instances on which happened and
|
|
validation error detail with possible actions (repair).
|
|
|
|
┌──────┬────────────────┬───────┐
|
|
│titles│ │actions│
|
|
│ │ │ │
|
|
│ │ Error detail │ │
|
|
│ │ │ │
|
|
│ │ │ │
|
|
├──────┴────────────────┴───────┤
|
|
│ Publish buttons │
|
|
└───────────────────────────────┘
|
|
"""
|
|
def __init__(self, controller, parent):
|
|
super(ValidationsWidget, self).__init__(parent)
|
|
|
|
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
|
|
|
errors_scroll = VerticallScrollArea(self)
|
|
errors_scroll.setWidgetResizable(True)
|
|
|
|
errors_widget = QtWidgets.QWidget(errors_scroll)
|
|
errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
|
|
errors_layout = QtWidgets.QVBoxLayout(errors_widget)
|
|
errors_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
errors_scroll.setWidget(errors_widget)
|
|
|
|
error_details_frame = QtWidgets.QFrame(self)
|
|
error_details_input = QtWidgets.QTextEdit(error_details_frame)
|
|
error_details_input.setObjectName("InfoText")
|
|
error_details_input.setTextInteractionFlags(
|
|
QtCore.Qt.TextBrowserInteraction
|
|
)
|
|
|
|
actions_widget = ValidateActionsWidget(controller, self)
|
|
actions_widget.setMinimumWidth(140)
|
|
|
|
error_details_layout = QtWidgets.QHBoxLayout(error_details_frame)
|
|
error_details_layout.addWidget(error_details_input, 1)
|
|
error_details_layout.addWidget(actions_widget, 0)
|
|
|
|
content_layout = QtWidgets.QHBoxLayout()
|
|
content_layout.setSpacing(0)
|
|
content_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
content_layout.addWidget(errors_scroll, 0)
|
|
content_layout.addWidget(error_details_frame, 1)
|
|
|
|
top_label = QtWidgets.QLabel("Publish validation report", self)
|
|
top_label.setObjectName("PublishInfoMainLabel")
|
|
top_label.setAlignment(QtCore.Qt.AlignCenter)
|
|
|
|
layout = QtWidgets.QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.addWidget(top_label)
|
|
layout.addLayout(content_layout)
|
|
|
|
self._top_label = top_label
|
|
self._errors_widget = errors_widget
|
|
self._errors_layout = errors_layout
|
|
self._error_details_frame = error_details_frame
|
|
self._error_details_input = error_details_input
|
|
self._actions_widget = actions_widget
|
|
|
|
self._title_widgets = {}
|
|
self._error_info = {}
|
|
self._previous_select = None
|
|
|
|
def clear(self):
|
|
"""Delete all dynamic widgets and hide all wrappers."""
|
|
self._title_widgets = {}
|
|
self._error_info = {}
|
|
self._previous_select = None
|
|
while self._errors_layout.count():
|
|
item = self._errors_layout.takeAt(0)
|
|
widget = item.widget()
|
|
if widget:
|
|
widget.deleteLater()
|
|
|
|
self._top_label.setVisible(False)
|
|
self._error_details_frame.setVisible(False)
|
|
self._errors_widget.setVisible(False)
|
|
self._actions_widget.setVisible(False)
|
|
|
|
def set_errors(self, errors):
|
|
"""Set errors into context and created titles."""
|
|
self.clear()
|
|
if not errors:
|
|
return
|
|
|
|
self._top_label.setVisible(True)
|
|
self._error_details_frame.setVisible(True)
|
|
self._errors_widget.setVisible(True)
|
|
|
|
errors_by_title = []
|
|
for plugin_info in errors:
|
|
titles = []
|
|
error_info_by_title = {}
|
|
|
|
for error_info in plugin_info["errors"]:
|
|
exception = error_info["exception"]
|
|
title = exception.title
|
|
if title not in titles:
|
|
titles.append(title)
|
|
error_info_by_title[title] = []
|
|
error_info_by_title[title].append(
|
|
(error_info["instance"], exception)
|
|
)
|
|
|
|
for title in titles:
|
|
errors_by_title.append({
|
|
"plugin": plugin_info["plugin"],
|
|
"error_info": error_info_by_title[title],
|
|
"title": title
|
|
})
|
|
|
|
for idx, item in enumerate(errors_by_title):
|
|
widget = ValidationErrorTitleWidget(idx, item, self)
|
|
widget.selected.connect(self._on_select)
|
|
widget.instance_changed.connect(self._on_instance_change)
|
|
self._errors_layout.addWidget(widget)
|
|
self._title_widgets[idx] = widget
|
|
self._error_info[idx] = item
|
|
|
|
self._errors_layout.addStretch(1)
|
|
|
|
if self._title_widgets:
|
|
self._title_widgets[0].set_selected(True)
|
|
|
|
self.updateGeometry()
|
|
|
|
def _on_select(self, index):
|
|
if self._previous_select:
|
|
if self._previous_select.index == index:
|
|
return
|
|
self._previous_select.set_selected(False)
|
|
|
|
self._previous_select = self._title_widgets[index]
|
|
|
|
error_item = self._error_info[index]
|
|
|
|
self._actions_widget.set_plugin(error_item["plugin"])
|
|
|
|
self._update_description()
|
|
|
|
def _on_instance_change(self, index):
|
|
if self._previous_select and self._previous_select.index != index:
|
|
return
|
|
self._update_description()
|
|
|
|
def _update_description(self):
|
|
description = self._previous_select.current_desctiption_text()
|
|
if commonmark:
|
|
html = commonmark.commonmark(description)
|
|
self._error_details_input.setHtml(html)
|
|
else:
|
|
self._error_details_input.setMarkdown(description)
|