mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
Publisher: Show instances in report page (#4915)
* renamed 'validations_widget.py' to 'report_page.py' * Implemented base logic and widgets for logs * make one report page * added missing imports * added missing constants * move and rename 'VerticallScrollArea' to 'VerticalScrollArea' * Validation erro item have id * use 'ReportPageWidget' in window * change 'bg-button-hover' key to 'bg-buttons-hover' in style colors * move publish actions widgets * Refactored how validation error title is showed * remove item id from validation error item but add id to group items * remove margins from actions widget * shrink publish frame on finished publishing * fix dash line draw * add missing styles * fix dash line in thumbnail widget * added crash widget and changed layout a little * added infor overlay message * export and copy report happens in main window * fix docstrings * added per plugin filtering for validation errors * added implementation of 'FlowLayout' * actions buttons are in flow layout * fix actions order * implemented expanding text edit widget * expand button has some signals and properties * description and details are separated widgets * fix typo * added constans to '__all__' * parse icon def is a function * change layout of widgets * fix log filtering * added state icon to instances * fix pyside6 issues * implemented 'ClassicExpandBtnLabel' with arrow images * modified details separator * added some spacing to layouts * fix syle of description inputs and progress color * removed unused import * add 'is_validation_error' to errored result * validation error has different icon in logs view * added plugin name to ValueError if happens * spacer before detail inputs moved out of detals widget * fix actions visible in craash report * ignore pyblish base classes * filter base plugins in discovery * use 'is' comparison instead of '__eq__' * fix action error handling * Fix handling of 'None' values in comparison * formatting fix * Report instance card have same margins as in create mode * publish instances are grouped by family * log messages are rstripped
This commit is contained in:
parent
d55211c337
commit
a73d19b612
21 changed files with 2373 additions and 834 deletions
|
|
@ -320,6 +320,14 @@ def publish_plugins_discover(paths=None):
|
|||
continue
|
||||
|
||||
for plugin in pyblish.plugin.plugins_from_module(module):
|
||||
# Ignore base plugin classes
|
||||
# NOTE 'pyblish.api.discover' does not ignore them!
|
||||
if (
|
||||
plugin is pyblish.api.Plugin
|
||||
or plugin is pyblish.api.ContextPlugin
|
||||
or plugin is pyblish.api.InstancePlugin
|
||||
):
|
||||
continue
|
||||
if not allow_duplicates and plugin.__name__ in plugin_names:
|
||||
result.duplicated_plugins.append(plugin)
|
||||
log.debug("Duplicate plug-in found: %s", plugin)
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
|
||||
"bg": "#2C313A",
|
||||
"bg-inputs": "#21252B",
|
||||
"bg-buttons": "#434a56",
|
||||
"bg-button-hover": "rgb(81, 86, 97)",
|
||||
"bg-buttons": "rgb(67, 74, 86)",
|
||||
"bg-buttons-hover": "rgb(81, 86, 97)",
|
||||
"bg-inputs-disabled": "#2C313A",
|
||||
"bg-buttons-disabled": "#434a56",
|
||||
|
||||
|
|
@ -66,7 +66,9 @@
|
|||
"bg-success": "#458056",
|
||||
"bg-success-hover": "#55a066",
|
||||
"bg-error": "#AD2E2E",
|
||||
"bg-error-hover": "#C93636"
|
||||
"bg-error-hover": "#C93636",
|
||||
"bg-info": "rgb(63, 98, 121)",
|
||||
"bg-info-hover": "rgb(81, 146, 181)"
|
||||
},
|
||||
"tab-widget": {
|
||||
"bg": "#21252B",
|
||||
|
|
@ -94,6 +96,7 @@
|
|||
"crash": "#FF6432",
|
||||
"success": "#458056",
|
||||
"warning": "#ffc671",
|
||||
"progress": "rgb(194, 226, 236)",
|
||||
"tab-bg": "#16191d",
|
||||
"list-view-group": {
|
||||
"bg": "#434a56",
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ QPushButton {
|
|||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
color: {color:font-hover};
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ QToolButton {
|
|||
}
|
||||
|
||||
QToolButton:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
color: {color:font-hover};
|
||||
}
|
||||
|
||||
|
|
@ -722,6 +722,13 @@ OverlayMessageWidget[type="error"]:hover {
|
|||
background: {color:overlay-messages:bg-error-hover};
|
||||
}
|
||||
|
||||
OverlayMessageWidget[type="info"] {
|
||||
background: {color:overlay-messages:bg-info};
|
||||
}
|
||||
OverlayMessageWidget[type="info"]:hover {
|
||||
background: {color:overlay-messages:bg-info-hover};
|
||||
}
|
||||
|
||||
OverlayMessageWidget QWidget {
|
||||
background: transparent;
|
||||
}
|
||||
|
|
@ -749,10 +756,11 @@ OverlayMessageWidget QWidget {
|
|||
}
|
||||
|
||||
#InfoText {
|
||||
padding-left: 30px;
|
||||
padding-top: 20px;
|
||||
padding-left: 0px;
|
||||
padding-top: 0px;
|
||||
padding-right: 20px;
|
||||
background: transparent;
|
||||
border: 1px solid {color:border};
|
||||
border: none;
|
||||
}
|
||||
|
||||
#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor {
|
||||
|
|
@ -914,7 +922,7 @@ PixmapButton{
|
|||
background: {color:bg-buttons};
|
||||
}
|
||||
PixmapButton:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
}
|
||||
PixmapButton:disabled {
|
||||
background: {color:bg-buttons-disabled};
|
||||
|
|
@ -925,7 +933,7 @@ PixmapButton:disabled {
|
|||
background: {color:bg-view};
|
||||
}
|
||||
#ThumbnailPixmapHoverButton:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
}
|
||||
|
||||
#CreatorDetailedDescription {
|
||||
|
|
@ -946,7 +954,7 @@ PixmapButton:disabled {
|
|||
}
|
||||
|
||||
#CreateDialogHelpButton:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
}
|
||||
#CreateDialogHelpButton QWidget {
|
||||
background: transparent;
|
||||
|
|
@ -1005,7 +1013,7 @@ PixmapButton:disabled {
|
|||
border-radius: 0.2em;
|
||||
}
|
||||
#CardViewWidget:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
}
|
||||
#CardViewWidget[state="selected"] {
|
||||
background: {color:bg-view-selection};
|
||||
|
|
@ -1032,7 +1040,7 @@ PixmapButton:disabled {
|
|||
}
|
||||
|
||||
#PublishInfoFrame[state="3"], #PublishInfoFrame[state="4"] {
|
||||
background: rgb(194, 226, 236);
|
||||
background: {color:publisher:progress};
|
||||
}
|
||||
|
||||
#PublishInfoFrame QLabel {
|
||||
|
|
@ -1040,6 +1048,11 @@ PixmapButton:disabled {
|
|||
font-style: bold;
|
||||
}
|
||||
|
||||
#PublishReportHeader {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#PublishInfoMainLabel {
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
|
@ -1060,7 +1073,7 @@ ValidationArtistMessage QLabel {
|
|||
}
|
||||
|
||||
#ValidationActionButton:hover {
|
||||
background: {color:bg-button-hover};
|
||||
background: {color:bg-buttons-hover};
|
||||
color: {color:font-hover};
|
||||
}
|
||||
|
||||
|
|
@ -1090,6 +1103,35 @@ ValidationArtistMessage QLabel {
|
|||
border-left: 1px solid {color:border};
|
||||
}
|
||||
|
||||
#PublishInstancesDetails {
|
||||
border: 1px solid {color:border};
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
#InstancesLogsView {
|
||||
border: 1px solid {color:border};
|
||||
background: {color:bg-view};
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
#PublishLogMessage {
|
||||
font-family: "Noto Sans Mono";
|
||||
}
|
||||
|
||||
#PublishInstanceLogsLabel {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#PublishCrashMainLabel{
|
||||
font-weight: bold;
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
#PublishCrashReportLabel {
|
||||
font-weight: bold;
|
||||
font-size: 13pt;
|
||||
}
|
||||
|
||||
#AssetNameInputWidget {
|
||||
background: {color:bg-inputs};
|
||||
border: 1px solid {color:border};
|
||||
|
|
|
|||
|
|
@ -198,29 +198,33 @@ class DropEmpty(QtWidgets.QWidget):
|
|||
|
||||
def paintEvent(self, event):
|
||||
super(DropEmpty, self).paintEvent(event)
|
||||
painter = QtGui.QPainter(self)
|
||||
|
||||
pen = QtGui.QPen()
|
||||
pen.setWidth(1)
|
||||
pen.setBrush(QtCore.Qt.darkGray)
|
||||
pen.setStyle(QtCore.Qt.DashLine)
|
||||
painter.setPen(pen)
|
||||
content_margins = self.layout().contentsMargins()
|
||||
pen.setWidth(1)
|
||||
|
||||
left_m = content_margins.left()
|
||||
top_m = content_margins.top()
|
||||
rect = QtCore.QRect(
|
||||
content_margins = self.layout().contentsMargins()
|
||||
rect = self.rect()
|
||||
left_m = content_margins.left() + pen.width()
|
||||
top_m = content_margins.top() + pen.width()
|
||||
new_rect = QtCore.QRect(
|
||||
left_m,
|
||||
top_m,
|
||||
(
|
||||
self.rect().width()
|
||||
rect.width()
|
||||
- (left_m + content_margins.right() + pen.width())
|
||||
),
|
||||
(
|
||||
self.rect().height()
|
||||
rect.height()
|
||||
- (top_m + content_margins.bottom() + pen.width())
|
||||
)
|
||||
)
|
||||
painter.drawRect(rect)
|
||||
|
||||
painter = QtGui.QPainter(self)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
painter.setPen(pen)
|
||||
painter.drawRect(new_rect)
|
||||
|
||||
|
||||
class FilesModel(QtGui.QStandardItemModel):
|
||||
|
|
|
|||
|
|
@ -35,9 +35,13 @@ ResetKeySequence = QtGui.QKeySequence(
|
|||
|
||||
__all__ = (
|
||||
"CONTEXT_ID",
|
||||
"CONTEXT_LABEL",
|
||||
|
||||
"VARIANT_TOOLTIP",
|
||||
|
||||
"INPUTS_LAYOUT_HSPACING",
|
||||
"INPUTS_LAYOUT_VSPACING",
|
||||
|
||||
"INSTANCE_ID_ROLE",
|
||||
"SORT_VALUE_ROLE",
|
||||
"IS_GROUP_ROLE",
|
||||
|
|
@ -47,4 +51,6 @@ __all__ = (
|
|||
"FAMILY_ROLE",
|
||||
"GROUP_ROLE",
|
||||
"CONVERTER_IDENTIFIER_ROLE",
|
||||
|
||||
"ResetKeySequence",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ PLUGIN_ORDER_OFFSET = 0.5
|
|||
|
||||
class CardMessageTypes:
|
||||
standard = None
|
||||
info = "info"
|
||||
error = "error"
|
||||
|
||||
|
||||
|
|
@ -220,7 +221,12 @@ class PublishReportMaker:
|
|||
|
||||
def _add_plugin_data_item(self, plugin):
|
||||
if plugin in self._stored_plugins:
|
||||
raise ValueError("Plugin is already stored")
|
||||
# A plugin would be processed more than once. What can cause it:
|
||||
# - there is a bug in controller
|
||||
# - plugin class is imported into multiple files
|
||||
# - this can happen even with base classes from 'pyblish'
|
||||
raise ValueError(
|
||||
"Plugin '{}' is already stored".format(str(plugin)))
|
||||
|
||||
self._stored_plugins.append(plugin)
|
||||
|
||||
|
|
@ -239,6 +245,7 @@ class PublishReportMaker:
|
|||
label = plugin.label
|
||||
|
||||
return {
|
||||
"id": plugin.id,
|
||||
"name": plugin.__name__,
|
||||
"label": label,
|
||||
"order": plugin.order,
|
||||
|
|
@ -324,7 +331,7 @@ class PublishReportMaker:
|
|||
"instances": instances_details,
|
||||
"context": self._extract_context_data(self._current_context),
|
||||
"crashed_file_paths": crashed_file_paths,
|
||||
"id": str(uuid.uuid4()),
|
||||
"id": uuid.uuid4().hex,
|
||||
"report_version": "1.0.0"
|
||||
}
|
||||
|
||||
|
|
@ -342,7 +349,9 @@ class PublishReportMaker:
|
|||
"label": instance.data.get("label"),
|
||||
"family": instance.data["family"],
|
||||
"families": instance.data.get("families") or [],
|
||||
"exists": exists
|
||||
"exists": exists,
|
||||
"creator_identifier": instance.data.get("creator_identifier"),
|
||||
"instance_id": instance.data.get("instance_id"),
|
||||
}
|
||||
|
||||
def _extract_instance_log_items(self, result):
|
||||
|
|
@ -388,8 +397,11 @@ class PublishReportMaker:
|
|||
exception = result.get("error")
|
||||
if exception:
|
||||
fname, line_no, func, exc = exception.traceback
|
||||
# Action result does not have 'is_validation_error'
|
||||
is_validation_error = result.get("is_validation_error", False)
|
||||
output.append({
|
||||
"type": "error",
|
||||
"is_validation_error": is_validation_error,
|
||||
"msg": str(exception),
|
||||
"filename": str(fname),
|
||||
"lineno": str(line_no),
|
||||
|
|
@ -426,13 +438,15 @@ class PublishPluginsProxy:
|
|||
plugin_id = plugin.id
|
||||
plugins_by_id[plugin_id] = plugin
|
||||
|
||||
action_ids = set()
|
||||
action_ids = []
|
||||
action_ids_by_plugin_id[plugin_id] = action_ids
|
||||
|
||||
actions = getattr(plugin, "actions", None) or []
|
||||
for action in actions:
|
||||
action_id = action.id
|
||||
action_ids.add(action_id)
|
||||
if action_id in actions_by_id:
|
||||
continue
|
||||
action_ids.append(action_id)
|
||||
actions_by_id[action_id] = action
|
||||
|
||||
self._plugins_by_id = plugins_by_id
|
||||
|
|
@ -461,7 +475,7 @@ class PublishPluginsProxy:
|
|||
return plugin.id
|
||||
|
||||
def get_plugin_action_items(self, plugin_id):
|
||||
"""Get plugin action items for plugin by it's id.
|
||||
"""Get plugin action items for plugin by its id.
|
||||
|
||||
Args:
|
||||
plugin_id (str): Publish plugin id.
|
||||
|
|
@ -568,7 +582,7 @@ class ValidationErrorItem:
|
|||
context_validation,
|
||||
title,
|
||||
description,
|
||||
detail,
|
||||
detail
|
||||
):
|
||||
self.instance_id = instance_id
|
||||
self.instance_label = instance_label
|
||||
|
|
@ -677,6 +691,8 @@ class PublishValidationErrorsReport:
|
|||
|
||||
for title in titles:
|
||||
grouped_error_items.append({
|
||||
"id": uuid.uuid4().hex,
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_action_items": list(plugin_action_items),
|
||||
"error_items": error_items_by_title[title],
|
||||
"title": title
|
||||
|
|
@ -2379,7 +2395,8 @@ class PublisherController(BasePublisherController):
|
|||
yield MainThreadItem(self.stop_publish)
|
||||
|
||||
# Add plugin to publish report
|
||||
self._publish_report.add_plugin_iter(plugin, self._publish_context)
|
||||
self._publish_report.add_plugin_iter(
|
||||
plugin, self._publish_context)
|
||||
|
||||
# WARNING This is hack fix for optional plugins
|
||||
if not self._is_publish_plugin_active(plugin):
|
||||
|
|
@ -2461,14 +2478,14 @@ class PublisherController(BasePublisherController):
|
|||
plugin, self._publish_context, instance
|
||||
)
|
||||
|
||||
self._publish_report.add_result(result)
|
||||
|
||||
exception = result.get("error")
|
||||
if exception:
|
||||
has_validation_error = False
|
||||
if (
|
||||
isinstance(exception, PublishValidationError)
|
||||
and not self.publish_has_validated
|
||||
):
|
||||
has_validation_error = True
|
||||
self._add_validation_error(result)
|
||||
|
||||
else:
|
||||
|
|
@ -2482,6 +2499,10 @@ class PublisherController(BasePublisherController):
|
|||
self.publish_error_msg = msg
|
||||
self.publish_has_crashed = True
|
||||
|
||||
result["is_validation_error"] = has_validation_error
|
||||
|
||||
self._publish_report.add_result(result)
|
||||
|
||||
self._publish_next_process()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,11 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit):
|
|||
super(ZoomPlainText, self).wheelEvent(event)
|
||||
return
|
||||
|
||||
degrees = float(event.delta()) / 8
|
||||
if hasattr(event, "angleDelta"):
|
||||
delta = event.angleDelta().y()
|
||||
else:
|
||||
delta = event.delta()
|
||||
degrees = float(delta) / 8
|
||||
steps = int(ceil(degrees / 5))
|
||||
self._scheduled_scalings += steps
|
||||
if (self._scheduled_scalings * steps < 0):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from .help_widget import (
|
|||
from .publish_frame import PublishFrame
|
||||
from .tabs_widget import PublisherTabsWidget
|
||||
from .overview_widget import OverviewWidget
|
||||
from .validations_widget import ValidationsWidget
|
||||
from .report_page import ReportPageWidget
|
||||
|
||||
|
||||
__all__ = (
|
||||
|
|
@ -40,5 +40,5 @@ __all__ = (
|
|||
|
||||
"PublisherTabsWidget",
|
||||
"OverviewWidget",
|
||||
"ValidationsWidget",
|
||||
"ReportPageWidget",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class BaseGroupWidget(QtWidgets.QWidget):
|
|||
return self._group
|
||||
|
||||
def get_widget_by_item_id(self, item_id):
|
||||
"""Get instance widget by it's id."""
|
||||
"""Get instance widget by its id."""
|
||||
|
||||
return self._widgets_by_id.get(item_id)
|
||||
|
||||
|
|
@ -702,8 +702,8 @@ class InstanceCardView(AbstractInstanceView):
|
|||
|
||||
for group_name in sorted_group_names:
|
||||
group_icons = {
|
||||
idenfier: self._controller.get_creator_icon(idenfier)
|
||||
for idenfier in identifiers_by_group[group_name]
|
||||
identifier: self._controller.get_creator_icon(identifier)
|
||||
for identifier in identifiers_by_group[group_name]
|
||||
}
|
||||
if group_name in self._widgets_by_group:
|
||||
group_widget = self._widgets_by_group[group_name]
|
||||
|
|
|
|||
BIN
openpype/tools/publisher/widgets/images/error.png
Normal file
BIN
openpype/tools/publisher/widgets/images/error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
openpype/tools/publisher/widgets/images/success.png
Normal file
BIN
openpype/tools/publisher/widgets/images/success.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 11 KiB |
|
|
@ -468,45 +468,14 @@ class PublishFrame(QtWidgets.QWidget):
|
|||
widget.setProperty("state", state)
|
||||
widget.style().polish(widget)
|
||||
|
||||
def _copy_report(self):
|
||||
logs = self._controller.get_publish_report()
|
||||
logs_string = json.dumps(logs, indent=4)
|
||||
|
||||
mime_data = QtCore.QMimeData()
|
||||
mime_data.setText(logs_string)
|
||||
QtWidgets.QApplication.instance().clipboard().setMimeData(
|
||||
mime_data
|
||||
)
|
||||
|
||||
def _export_report(self):
|
||||
default_filename = "publish-report-{}".format(
|
||||
time.strftime("%y%m%d-%H-%M")
|
||||
)
|
||||
default_filepath = os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
default_filename
|
||||
)
|
||||
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self, "Save report", default_filepath, ".json"
|
||||
)
|
||||
if not ext or not new_filepath:
|
||||
return
|
||||
|
||||
logs = self._controller.get_publish_report()
|
||||
full_path = new_filepath + ext
|
||||
dir_path = os.path.dirname(full_path)
|
||||
if not os.path.exists(dir_path):
|
||||
os.makedirs(dir_path)
|
||||
|
||||
with open(full_path, "w") as file_stream:
|
||||
json.dump(logs, file_stream)
|
||||
|
||||
def _on_report_triggered(self, identifier):
|
||||
if identifier == "export_report":
|
||||
self._export_report()
|
||||
self._controller.event_system.emit(
|
||||
"export_report.request", {}, "publish_frame")
|
||||
|
||||
elif identifier == "copy_report":
|
||||
self._copy_report()
|
||||
self._controller.event_system.emit(
|
||||
"copy_report.request", {}, "publish_frame")
|
||||
|
||||
elif identifier == "go_to_report":
|
||||
self.details_page_requested.emit()
|
||||
|
|
|
|||
1876
openpype/tools/publisher/widgets/report_page.py
Normal file
1876
openpype/tools/publisher/widgets/report_page.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -75,6 +75,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
|
|||
|
||||
painter = QtGui.QPainter()
|
||||
painter.begin(self)
|
||||
painter.setRenderHint(QtGui.QPainter.Antialiasing)
|
||||
painter.drawPixmap(0, 0, self._cached_pix)
|
||||
painter.end()
|
||||
|
||||
|
|
@ -183,6 +184,18 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
|
|||
backgrounded_images.append(new_pix)
|
||||
return backgrounded_images
|
||||
|
||||
def _paint_dash_line(self, painter, rect):
|
||||
pen = QtGui.QPen()
|
||||
pen.setWidth(1)
|
||||
pen.setBrush(QtCore.Qt.darkGray)
|
||||
pen.setStyle(QtCore.Qt.DashLine)
|
||||
|
||||
new_rect = rect.adjusted(1, 1, -1, -1)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(QtCore.Qt.transparent)
|
||||
# painter.drawRect(rect)
|
||||
painter.drawRect(new_rect)
|
||||
|
||||
def _cache_pix(self):
|
||||
rect = self.rect()
|
||||
rect_width = rect.width()
|
||||
|
|
@ -264,13 +277,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget):
|
|||
|
||||
# Draw drop enabled dashes
|
||||
if used_default_pix:
|
||||
pen = QtGui.QPen()
|
||||
pen.setWidth(1)
|
||||
pen.setBrush(QtCore.Qt.darkGray)
|
||||
pen.setStyle(QtCore.Qt.DashLine)
|
||||
final_painter.setPen(pen)
|
||||
final_painter.setBrush(QtCore.Qt.transparent)
|
||||
final_painter.drawRect(rect)
|
||||
self._paint_dash_line(final_painter, rect)
|
||||
|
||||
final_painter.end()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,715 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
try:
|
||||
import commonmark
|
||||
except Exception:
|
||||
commonmark = None
|
||||
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from openpype.tools.utils import BaseClickableFrame, ClickableFrame
|
||||
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.QAbstractItemView.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 = ClickableFrame(self)
|
||||
title_frame.setObjectName("ValidationErrorTitleFrame")
|
||||
|
||||
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(label_widget, 1)
|
||||
title_frame_layout.addWidget(toggle_instance_btn, 0)
|
||||
|
||||
instances_model = QtGui.QStandardItemModel()
|
||||
|
||||
help_text_by_instance_id = {}
|
||||
|
||||
items = []
|
||||
context_validation = False
|
||||
for error_item in error_info["error_items"]:
|
||||
context_validation = error_item.context_validation
|
||||
if context_validation:
|
||||
toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow)
|
||||
description = self._prepare_description(error_item)
|
||||
help_text_by_instance_id[None] = description
|
||||
# Add fake item to have minimum size hint of view widget
|
||||
items.append(QtGui.QStandardItem("Context"))
|
||||
continue
|
||||
|
||||
label = error_item.instance_label
|
||||
item = QtGui.QStandardItem(label)
|
||||
item.setFlags(
|
||||
QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
|
||||
)
|
||||
item.setData(label, QtCore.Qt.ToolTipRole)
|
||||
item.setData(error_item.instance_id, INSTANCE_ID_ROLE)
|
||||
items.append(item)
|
||||
description = self._prepare_description(error_item)
|
||||
help_text_by_instance_id[error_item.instance_id] = description
|
||||
|
||||
if items:
|
||||
root_item = instances_model.invisibleRootItem()
|
||||
root_item.appendRows(items)
|
||||
|
||||
instances_view = ValidationErrorInstanceList(self)
|
||||
instances_view.setModel(instances_model)
|
||||
|
||||
self.setLayoutDirection(QtCore.Qt.LeftToRight)
|
||||
|
||||
view_widget = QtWidgets.QWidget(self)
|
||||
view_layout = QtWidgets.QHBoxLayout(view_widget)
|
||||
view_layout.setContentsMargins(0, 0, 0, 0)
|
||||
view_layout.setSpacing(0)
|
||||
view_layout.addSpacing(14)
|
||||
view_layout.addWidget(instances_view, 0)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(title_frame, 0)
|
||||
layout.addWidget(view_widget, 0)
|
||||
view_widget.setVisible(False)
|
||||
|
||||
if not context_validation:
|
||||
toggle_instance_btn.clicked.connect(self._on_toggle_btn_click)
|
||||
|
||||
title_frame.clicked.connect(self._mouse_release_callback)
|
||||
instances_view.selectionModel().selectionChanged.connect(
|
||||
self._on_seleciton_change
|
||||
)
|
||||
|
||||
self._title_frame = title_frame
|
||||
|
||||
self._toggle_instance_btn = toggle_instance_btn
|
||||
|
||||
self._view_widget = view_widget
|
||||
|
||||
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
|
||||
|
||||
self._expanded = False
|
||||
|
||||
def sizeHint(self):
|
||||
result = super(ValidationErrorTitleWidget, self).sizeHint()
|
||||
expected_width = max(
|
||||
self._view_widget.minimumSizeHint().width(),
|
||||
self._view_widget.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, error_item):
|
||||
"""Prepare description text for detail intput.
|
||||
|
||||
Args:
|
||||
error_item (ValidationErrorItem): Item which hold information about
|
||||
validation error.
|
||||
|
||||
Returns:
|
||||
str: Prepared detailed description.
|
||||
"""
|
||||
|
||||
dsc = error_item.description
|
||||
detail = error_item.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_description_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.
|
||||
|
||||
Returns:
|
||||
bool: Item is selected or not.
|
||||
"""
|
||||
|
||||
return self._selected
|
||||
|
||||
@property
|
||||
def index(self):
|
||||
"""Widget's index set by parent.
|
||||
|
||||
Returns:
|
||||
int: Index of widget.
|
||||
"""
|
||||
|
||||
return self._index
|
||||
|
||||
def set_index(self, index):
|
||||
"""Set index of widget (called by parent).
|
||||
|
||||
Args:
|
||||
int: New index of widget.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# Clear instance view selection on deselect
|
||||
if not selected:
|
||||
self._instances_view.clearSelection()
|
||||
|
||||
# Skip if has same value
|
||||
if selected == self._selected:
|
||||
return
|
||||
|
||||
self._selected = selected
|
||||
self._change_style_property(selected)
|
||||
if selected:
|
||||
self.selected.emit(self._index)
|
||||
self._set_expanded(True)
|
||||
|
||||
def _on_toggle_btn_click(self):
|
||||
"""Show/hide instances list."""
|
||||
|
||||
self._set_expanded()
|
||||
|
||||
def _set_expanded(self, expanded=None):
|
||||
if expanded is None:
|
||||
expanded = not self._expanded
|
||||
|
||||
elif expanded is self._expanded:
|
||||
return
|
||||
|
||||
if expanded and self._context_validation:
|
||||
return
|
||||
|
||||
self._expanded = expanded
|
||||
self._view_widget.setVisible(expanded)
|
||||
if expanded:
|
||||
self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow)
|
||||
else:
|
||||
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
|
||||
|
||||
def _on_seleciton_change(self):
|
||||
sel_model = self._instances_view.selectionModel()
|
||||
if sel_model.selectedIndexes():
|
||||
self.instance_changed.emit(self._index)
|
||||
|
||||
|
||||
class ActionButton(BaseClickableFrame):
|
||||
"""Plugin's action callback button.
|
||||
|
||||
Action may have label or icon or both.
|
||||
|
||||
Args:
|
||||
plugin_action_item (PublishPluginActionItem): Action item that can be
|
||||
triggered by it's id.
|
||||
"""
|
||||
|
||||
action_clicked = QtCore.Signal(str, str)
|
||||
|
||||
def __init__(self, plugin_action_item, parent):
|
||||
super(ActionButton, self).__init__(parent)
|
||||
|
||||
self.setObjectName("ValidationActionButton")
|
||||
|
||||
self.plugin_action_item = plugin_action_item
|
||||
|
||||
action_label = plugin_action_item.label
|
||||
action_icon = plugin_action_item.icon
|
||||
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.plugin_action_item.plugin_id,
|
||||
self.plugin_action_item.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._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_error_item(self, error_item):
|
||||
"""Set selected plugin and show it's actions.
|
||||
|
||||
Clears current actions from widget and recreate them from the plugin.
|
||||
|
||||
Args:
|
||||
Dict[str, Any]: Object holding error items, title and possible
|
||||
actions to run.
|
||||
"""
|
||||
|
||||
self.clear()
|
||||
|
||||
if not error_item:
|
||||
self.setVisible(False)
|
||||
return
|
||||
|
||||
plugin_action_items = error_item["plugin_action_items"]
|
||||
for plugin_action_item in plugin_action_items:
|
||||
if not plugin_action_item.active:
|
||||
continue
|
||||
|
||||
if plugin_action_item.on_filter not in ("failed", "all"):
|
||||
continue
|
||||
|
||||
action_id = plugin_action_item.action_id
|
||||
self._actions_mapping[action_id] = plugin_action_item
|
||||
|
||||
action_btn = ActionButton(plugin_action_item, 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, plugin_id, action_id):
|
||||
self._controller.run_action(plugin_id, action_id)
|
||||
|
||||
|
||||
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 ValidationArtistMessage(QtWidgets.QWidget):
|
||||
def __init__(self, message, parent):
|
||||
super(ValidationArtistMessage, self).__init__(parent)
|
||||
|
||||
artist_msg_label = QtWidgets.QLabel(message, self)
|
||||
artist_msg_label.setAlignment(QtCore.Qt.AlignCenter)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(
|
||||
artist_msg_label, 1, QtCore.Qt.AlignCenter
|
||||
)
|
||||
|
||||
|
||||
class ValidationsWidget(QtWidgets.QFrame):
|
||||
"""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 │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└──────┴────────────────┴───────┘
|
||||
"""
|
||||
|
||||
def __init__(self, controller, parent):
|
||||
super(ValidationsWidget, self).__init__(parent)
|
||||
|
||||
# Before publishing
|
||||
before_publish_widget = ValidationArtistMessage(
|
||||
"Nothing to report until you run publish", self
|
||||
)
|
||||
# After success publishing
|
||||
publish_started_widget = ValidationArtistMessage(
|
||||
"So far so good", self
|
||||
)
|
||||
# After success publishing
|
||||
publish_stop_ok_widget = ValidationArtistMessage(
|
||||
"Publishing finished successfully", self
|
||||
)
|
||||
# After failed publishing (not with validation error)
|
||||
publish_stop_fail_widget = ValidationArtistMessage(
|
||||
"This is not your fault...", self
|
||||
)
|
||||
|
||||
# Validation errors
|
||||
validations_widget = QtWidgets.QWidget(self)
|
||||
|
||||
content_widget = QtWidgets.QWidget(validations_widget)
|
||||
|
||||
errors_scroll = VerticallScrollArea(content_widget)
|
||||
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(content_widget)
|
||||
error_details_input = QtWidgets.QTextEdit(error_details_frame)
|
||||
error_details_input.setObjectName("InfoText")
|
||||
error_details_input.setTextInteractionFlags(
|
||||
QtCore.Qt.TextBrowserInteraction
|
||||
)
|
||||
|
||||
actions_widget = ValidateActionsWidget(controller, content_widget)
|
||||
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_widget)
|
||||
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", content_widget
|
||||
)
|
||||
top_label.setObjectName("PublishInfoMainLabel")
|
||||
top_label.setAlignment(QtCore.Qt.AlignCenter)
|
||||
|
||||
validation_layout = QtWidgets.QVBoxLayout(validations_widget)
|
||||
validation_layout.setContentsMargins(0, 0, 0, 0)
|
||||
validation_layout.addWidget(top_label, 0)
|
||||
validation_layout.addWidget(content_widget, 1)
|
||||
|
||||
main_layout = QtWidgets.QStackedLayout(self)
|
||||
main_layout.addWidget(before_publish_widget)
|
||||
main_layout.addWidget(publish_started_widget)
|
||||
main_layout.addWidget(publish_stop_ok_widget)
|
||||
main_layout.addWidget(publish_stop_fail_widget)
|
||||
main_layout.addWidget(validations_widget)
|
||||
|
||||
main_layout.setCurrentWidget(before_publish_widget)
|
||||
|
||||
controller.event_system.add_callback(
|
||||
"publish.process.started", self._on_publish_start
|
||||
)
|
||||
controller.event_system.add_callback(
|
||||
"publish.reset.finished", self._on_publish_reset
|
||||
)
|
||||
controller.event_system.add_callback(
|
||||
"publish.process.stopped", self._on_publish_stop
|
||||
)
|
||||
|
||||
self._main_layout = main_layout
|
||||
|
||||
self._before_publish_widget = before_publish_widget
|
||||
self._publish_started_widget = publish_started_widget
|
||||
self._publish_stop_ok_widget = publish_stop_ok_widget
|
||||
self._publish_stop_fail_widget = publish_stop_fail_widget
|
||||
self._validations_widget = validations_widget
|
||||
|
||||
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
|
||||
|
||||
self._controller = controller
|
||||
|
||||
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, validation_error_report):
|
||||
"""Set errors into context and created titles.
|
||||
|
||||
Args:
|
||||
validation_error_report (PublishValidationErrorsReport): Report
|
||||
with information about validation errors and publish plugin
|
||||
actions.
|
||||
"""
|
||||
|
||||
self.clear()
|
||||
if not validation_error_report:
|
||||
return
|
||||
|
||||
self._top_label.setVisible(True)
|
||||
self._error_details_frame.setVisible(True)
|
||||
self._errors_widget.setVisible(True)
|
||||
|
||||
grouped_error_items = validation_error_report.group_items_by_title()
|
||||
for idx, error_info in enumerate(grouped_error_items):
|
||||
widget = ValidationErrorTitleWidget(idx, error_info, 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] = error_info
|
||||
|
||||
self._errors_layout.addStretch(1)
|
||||
|
||||
if self._title_widgets:
|
||||
self._title_widgets[0].set_selected(True)
|
||||
|
||||
self.updateGeometry()
|
||||
|
||||
def _set_current_widget(self, widget):
|
||||
self._main_layout.setCurrentWidget(widget)
|
||||
|
||||
def _on_publish_start(self):
|
||||
self._set_current_widget(self._publish_started_widget)
|
||||
|
||||
def _on_publish_reset(self):
|
||||
self._set_current_widget(self._before_publish_widget)
|
||||
|
||||
def _on_publish_stop(self):
|
||||
if self._controller.publish_has_crashed:
|
||||
self._set_current_widget(self._publish_stop_fail_widget)
|
||||
return
|
||||
|
||||
if self._controller.publish_has_validation_errors:
|
||||
validation_errors = self._controller.get_validation_errors()
|
||||
self._set_current_widget(self._validations_widget)
|
||||
self._set_errors(validation_errors)
|
||||
return
|
||||
|
||||
if self._controller.publish_has_finished:
|
||||
self._set_current_widget(self._publish_stop_ok_widget)
|
||||
return
|
||||
|
||||
self._set_current_widget(self._publish_started_widget)
|
||||
|
||||
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_error_item(error_item)
|
||||
|
||||
self._update_description()
|
||||
|
||||
def _on_instance_change(self, index):
|
||||
if self._previous_select and self._previous_select.index != index:
|
||||
self._title_widgets[index].set_selected(True)
|
||||
else:
|
||||
self._update_description()
|
||||
|
||||
def _update_description(self):
|
||||
description = self._previous_select.current_description_text()
|
||||
if commonmark:
|
||||
html = commonmark.commonmark(description)
|
||||
self._error_details_input.setHtml(html)
|
||||
elif hasattr(self._error_details_input, "setMarkdown"):
|
||||
self._error_details_input.setMarkdown(description)
|
||||
else:
|
||||
self._error_details_input.setText(description)
|
||||
|
|
@ -40,6 +40,41 @@ from ..constants import (
|
|||
INPUTS_LAYOUT_VSPACING,
|
||||
)
|
||||
|
||||
FA_PREFIXES = ["", "fa.", "fa5.", "fa5b.", "fa5s.", "ei.", "mdi."]
|
||||
|
||||
|
||||
def parse_icon_def(
|
||||
icon_def, default_width=None, default_height=None, color=None
|
||||
):
|
||||
if not icon_def:
|
||||
return None
|
||||
|
||||
if isinstance(icon_def, QtGui.QPixmap):
|
||||
return icon_def
|
||||
|
||||
color = color or "white"
|
||||
default_width = default_width or 512
|
||||
default_height = default_height or 512
|
||||
|
||||
if isinstance(icon_def, QtGui.QIcon):
|
||||
return icon_def.pixmap(default_width, default_height)
|
||||
|
||||
try:
|
||||
if os.path.exists(icon_def):
|
||||
return QtGui.QPixmap(icon_def)
|
||||
except Exception:
|
||||
# TODO logging
|
||||
pass
|
||||
|
||||
for prefix in FA_PREFIXES:
|
||||
try:
|
||||
icon_name = "{}{}".format(prefix, icon_def)
|
||||
icon = qtawesome.icon(icon_name, color=color)
|
||||
return icon.pixmap(default_width, default_height)
|
||||
except Exception:
|
||||
# TODO logging
|
||||
continue
|
||||
|
||||
|
||||
class PublishPixmapLabel(PixmapLabel):
|
||||
def _get_pix_size(self):
|
||||
|
|
@ -54,7 +89,6 @@ class IconValuePixmapLabel(PublishPixmapLabel):
|
|||
Handle icon parsing from creators/instances. Using of QAwesome module
|
||||
of path to images.
|
||||
"""
|
||||
fa_prefixes = ["", "fa."]
|
||||
default_size = 200
|
||||
|
||||
def __init__(self, icon_def, parent):
|
||||
|
|
@ -77,31 +111,9 @@ class IconValuePixmapLabel(PublishPixmapLabel):
|
|||
return pix
|
||||
|
||||
def _parse_icon_def(self, icon_def):
|
||||
if not icon_def:
|
||||
return self._default_pixmap()
|
||||
|
||||
if isinstance(icon_def, QtGui.QPixmap):
|
||||
return icon_def
|
||||
|
||||
if isinstance(icon_def, QtGui.QIcon):
|
||||
return icon_def.pixmap(self.default_size, self.default_size)
|
||||
|
||||
try:
|
||||
if os.path.exists(icon_def):
|
||||
return QtGui.QPixmap(icon_def)
|
||||
except Exception:
|
||||
# TODO logging
|
||||
pass
|
||||
|
||||
for prefix in self.fa_prefixes:
|
||||
try:
|
||||
icon_name = "{}{}".format(prefix, icon_def)
|
||||
icon = qtawesome.icon(icon_name, color="white")
|
||||
return icon.pixmap(self.default_size, self.default_size)
|
||||
except Exception:
|
||||
# TODO logging
|
||||
continue
|
||||
|
||||
icon = parse_icon_def(icon_def, self.default_size, self.default_size)
|
||||
if icon:
|
||||
return icon
|
||||
return self._default_pixmap()
|
||||
|
||||
|
||||
|
|
@ -692,6 +704,7 @@ class TasksCombobox(QtWidgets.QComboBox):
|
|||
style.drawControl(
|
||||
QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self
|
||||
)
|
||||
painter.end()
|
||||
|
||||
def is_valid(self):
|
||||
"""Are all selected items valid."""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import os
|
||||
import json
|
||||
import time
|
||||
import collections
|
||||
import copy
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
|
@ -15,10 +18,11 @@ from openpype.tools.utils import (
|
|||
|
||||
from .constants import ResetKeySequence
|
||||
from .publish_report_viewer import PublishReportViewerWidget
|
||||
from .control import CardMessageTypes
|
||||
from .control_qt import QtPublisherController
|
||||
from .widgets import (
|
||||
OverviewWidget,
|
||||
ValidationsWidget,
|
||||
ReportPageWidget,
|
||||
PublishFrame,
|
||||
|
||||
PublisherTabsWidget,
|
||||
|
|
@ -182,7 +186,7 @@ class PublisherWindow(QtWidgets.QDialog):
|
|||
controller, content_stacked_widget
|
||||
)
|
||||
|
||||
report_widget = ValidationsWidget(controller, parent)
|
||||
report_widget = ReportPageWidget(controller, parent)
|
||||
|
||||
# Details - Publish details
|
||||
publish_details_widget = PublishReportViewerWidget(
|
||||
|
|
@ -313,6 +317,13 @@ class PublisherWindow(QtWidgets.QDialog):
|
|||
controller.event_system.add_callback(
|
||||
"convertors.find.failed", self._on_convertor_error
|
||||
)
|
||||
controller.event_system.add_callback(
|
||||
"export_report.request", self._export_report
|
||||
)
|
||||
controller.event_system.add_callback(
|
||||
"copy_report.request", self._copy_report
|
||||
)
|
||||
|
||||
|
||||
# Store extra header widget for TrayPublisher
|
||||
# - can be used to add additional widgets to header between context
|
||||
|
|
@ -825,6 +836,9 @@ class PublisherWindow(QtWidgets.QDialog):
|
|||
self._validate_btn.setEnabled(validate_enabled)
|
||||
self._publish_btn.setEnabled(publish_enabled)
|
||||
|
||||
if not publish_enabled:
|
||||
self._publish_frame.set_shrunk_state(True)
|
||||
|
||||
self._update_publish_details_widget()
|
||||
|
||||
def _validate_create_instances(self):
|
||||
|
|
@ -941,6 +955,46 @@ class PublisherWindow(QtWidgets.QDialog):
|
|||
under_mouse = widget_x < global_pos.x()
|
||||
self._create_overlay_button.set_under_mouse(under_mouse)
|
||||
|
||||
def _copy_report(self):
|
||||
logs = self._controller.get_publish_report()
|
||||
logs_string = json.dumps(logs, indent=4)
|
||||
|
||||
mime_data = QtCore.QMimeData()
|
||||
mime_data.setText(logs_string)
|
||||
QtWidgets.QApplication.instance().clipboard().setMimeData(
|
||||
mime_data
|
||||
)
|
||||
self._controller.emit_card_message(
|
||||
"Report added to clipboard",
|
||||
CardMessageTypes.info)
|
||||
|
||||
def _export_report(self):
|
||||
default_filename = "publish-report-{}".format(
|
||||
time.strftime("%y%m%d-%H-%M")
|
||||
)
|
||||
default_filepath = os.path.join(
|
||||
os.path.expanduser("~"),
|
||||
default_filename
|
||||
)
|
||||
new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName(
|
||||
self, "Save report", default_filepath, ".json"
|
||||
)
|
||||
if not ext or not new_filepath:
|
||||
return
|
||||
|
||||
logs = self._controller.get_publish_report()
|
||||
full_path = new_filepath + ext
|
||||
dir_path = os.path.dirname(full_path)
|
||||
if not os.path.exists(dir_path):
|
||||
os.makedirs(dir_path)
|
||||
|
||||
with open(full_path, "w") as file_stream:
|
||||
json.dump(logs, file_stream)
|
||||
|
||||
self._controller.emit_card_message(
|
||||
"Report saved",
|
||||
CardMessageTypes.info)
|
||||
|
||||
|
||||
class ErrorsMessageBox(ErrorMessageBox):
|
||||
def __init__(self, error_title, failed_info, message_start, parent):
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
from .layouts import FlowLayout
|
||||
from .widgets import (
|
||||
FocusSpinBox,
|
||||
FocusDoubleSpinBox,
|
||||
ComboBox,
|
||||
CustomTextComboBox,
|
||||
PlaceholderLineEdit,
|
||||
ExpandingTextEdit,
|
||||
BaseClickableFrame,
|
||||
ClickableFrame,
|
||||
ClickableLabel,
|
||||
ExpandBtn,
|
||||
ClassicExpandBtn,
|
||||
PixmapLabel,
|
||||
IconButton,
|
||||
PixmapButton,
|
||||
|
|
@ -37,15 +40,19 @@ from .overlay_messages import (
|
|||
|
||||
|
||||
__all__ = (
|
||||
"FlowLayout",
|
||||
|
||||
"FocusSpinBox",
|
||||
"FocusDoubleSpinBox",
|
||||
"ComboBox",
|
||||
"CustomTextComboBox",
|
||||
"PlaceholderLineEdit",
|
||||
"ExpandingTextEdit",
|
||||
"BaseClickableFrame",
|
||||
"ClickableFrame",
|
||||
"ClickableLabel",
|
||||
"ExpandBtn",
|
||||
"ClassicExpandBtn",
|
||||
"PixmapLabel",
|
||||
"IconButton",
|
||||
"PixmapButton",
|
||||
|
|
|
|||
150
openpype/tools/utils/layouts.py
Normal file
150
openpype/tools/utils/layouts.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from qtpy import QtWidgets, QtCore
|
||||
|
||||
|
||||
class FlowLayout(QtWidgets.QLayout):
|
||||
"""Layout that organize widgets by minimum size into a flow layout.
|
||||
|
||||
Layout is putting widget from left to right and top to bottom. When widget
|
||||
can't fit a row it is added to next line. Minimum size matches widget with
|
||||
biggest 'sizeHint' width and height using calculated geometry.
|
||||
|
||||
Content margins are part of calculations. It is possible to define
|
||||
horizontal and vertical spacing.
|
||||
|
||||
Layout does not support stretch and spacing items.
|
||||
|
||||
Todos:
|
||||
Unified width concept -> use width of largest item so all of them are
|
||||
same. This could allow to have minimum columns option too.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(FlowLayout, self).__init__(parent)
|
||||
|
||||
# spaces between each item
|
||||
self._horizontal_spacing = 5
|
||||
self._vertical_spacing = 5
|
||||
|
||||
self._items = []
|
||||
|
||||
def __del__(self):
|
||||
while self.count():
|
||||
self.takeAt(0, False)
|
||||
|
||||
def isEmpty(self):
|
||||
for item in self._items:
|
||||
if not item.isEmpty():
|
||||
return False
|
||||
return True
|
||||
|
||||
def setSpacing(self, spacing):
|
||||
self._horizontal_spacing = spacing
|
||||
self._vertical_spacing = spacing
|
||||
self.invalidate()
|
||||
|
||||
def setHorizontalSpacing(self, spacing):
|
||||
self._horizontal_spacing = spacing
|
||||
self.invalidate()
|
||||
|
||||
def setVerticalSpacing(self, spacing):
|
||||
self._vertical_spacing = spacing
|
||||
self.invalidate()
|
||||
|
||||
def addItem(self, item):
|
||||
self._items.append(item)
|
||||
self.invalidate()
|
||||
|
||||
def count(self):
|
||||
return len(self._items)
|
||||
|
||||
def itemAt(self, index):
|
||||
if 0 <= index < len(self._items):
|
||||
return self._items[index]
|
||||
return None
|
||||
|
||||
def takeAt(self, index, invalidate=True):
|
||||
if 0 <= index < len(self._items):
|
||||
item = self._items.pop(index)
|
||||
if invalidate:
|
||||
self.invalidate()
|
||||
return item
|
||||
return None
|
||||
|
||||
def expandingDirections(self):
|
||||
return QtCore.Qt.Orientations(QtCore.Qt.Vertical)
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width):
|
||||
return self._setup_geometry(QtCore.QRect(0, 0, width, 0), True)
|
||||
|
||||
def setGeometry(self, rect):
|
||||
super(FlowLayout, self).setGeometry(rect)
|
||||
self._setup_geometry(rect)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
size = QtCore.QSize(0, 0)
|
||||
for item in self._items:
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
parent = widget.parent()
|
||||
if not widget.isVisibleTo(parent):
|
||||
continue
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
|
||||
if size.width() < 1 or size.height() < 1:
|
||||
return size
|
||||
l_margin, t_margin, r_margin, b_margin = self.getContentsMargins()
|
||||
size += QtCore.QSize(l_margin + r_margin, t_margin + b_margin)
|
||||
return size
|
||||
|
||||
def _setup_geometry(self, rect, only_calculate=False):
|
||||
h_spacing = self._horizontal_spacing
|
||||
v_spacing = self._vertical_spacing
|
||||
l_margin, t_margin, r_margin, b_margin = self.getContentsMargins()
|
||||
|
||||
left_x = rect.x() + l_margin
|
||||
top_y = rect.y() + t_margin
|
||||
pos_x = left_x
|
||||
pos_y = top_y
|
||||
row_height = 0
|
||||
for item in self._items:
|
||||
item_hint = item.sizeHint()
|
||||
item_width = item_hint.width()
|
||||
item_height = item_hint.height()
|
||||
if item_width < 1 or item_height < 1:
|
||||
continue
|
||||
|
||||
end_x = pos_x + item_width
|
||||
|
||||
wrap = (
|
||||
row_height > 0
|
||||
and (
|
||||
end_x > rect.right()
|
||||
or (end_x + r_margin) > rect.right()
|
||||
)
|
||||
)
|
||||
if not wrap:
|
||||
next_pos_x = end_x + h_spacing
|
||||
else:
|
||||
pos_x = left_x
|
||||
pos_y += row_height + v_spacing
|
||||
next_pos_x = pos_x + item_width + h_spacing
|
||||
row_height = 0
|
||||
|
||||
if not only_calculate:
|
||||
item.setGeometry(
|
||||
QtCore.QRect(pos_x, pos_y, item_width, item_height)
|
||||
)
|
||||
|
||||
pos_x = next_pos_x
|
||||
row_height = max(row_height, item_height)
|
||||
|
||||
height = (pos_y - top_y) + row_height
|
||||
if height > 0:
|
||||
height += b_margin
|
||||
return height
|
||||
|
|
@ -101,6 +101,46 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
|
|||
self.setPalette(filter_palette)
|
||||
|
||||
|
||||
class ExpandingTextEdit(QtWidgets.QTextEdit):
|
||||
"""QTextEdit which does not have sroll area but expands height."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(ExpandingTextEdit, self).__init__(parent)
|
||||
|
||||
size_policy = self.sizePolicy()
|
||||
size_policy.setHeightForWidth(True)
|
||||
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred)
|
||||
self.setSizePolicy(size_policy)
|
||||
|
||||
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||||
|
||||
doc = self.document()
|
||||
doc.contentsChanged.connect(self._on_doc_change)
|
||||
|
||||
def _on_doc_change(self):
|
||||
self.updateGeometry()
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width):
|
||||
margins = self.contentsMargins()
|
||||
|
||||
document_width = 0
|
||||
if width >= margins.left() + margins.right():
|
||||
document_width = width - margins.left() - margins.right()
|
||||
|
||||
document = self.document().clone()
|
||||
document.setTextWidth(document_width)
|
||||
|
||||
return margins.top() + document.size().height() + margins.bottom()
|
||||
|
||||
def sizeHint(self):
|
||||
width = super(ExpandingTextEdit, self).sizeHint().width()
|
||||
return QtCore.QSize(width, self.heightForWidth(width))
|
||||
|
||||
|
||||
class BaseClickableFrame(QtWidgets.QFrame):
|
||||
"""Widget that catch left mouse click and can trigger a callback.
|
||||
|
||||
|
|
@ -161,19 +201,34 @@ class ClickableLabel(QtWidgets.QLabel):
|
|||
|
||||
class ExpandBtnLabel(QtWidgets.QLabel):
|
||||
"""Label showing expand icon meant for ExpandBtn."""
|
||||
state_changed = QtCore.Signal()
|
||||
|
||||
|
||||
def __init__(self, parent):
|
||||
super(ExpandBtnLabel, self).__init__(parent)
|
||||
self._source_collapsed_pix = QtGui.QPixmap(
|
||||
get_style_image_path("branch_closed")
|
||||
)
|
||||
self._source_expanded_pix = QtGui.QPixmap(
|
||||
get_style_image_path("branch_open")
|
||||
)
|
||||
self._source_collapsed_pix = self._create_collapsed_pixmap()
|
||||
self._source_expanded_pix = self._create_expanded_pixmap()
|
||||
|
||||
self._current_image = self._source_collapsed_pix
|
||||
self._collapsed = True
|
||||
|
||||
def set_collapsed(self, collapsed):
|
||||
def _create_collapsed_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("branch_closed")
|
||||
)
|
||||
|
||||
def _create_expanded_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("branch_open")
|
||||
)
|
||||
|
||||
@property
|
||||
def collapsed(self):
|
||||
return self._collapsed
|
||||
|
||||
def set_collapsed(self, collapsed=None):
|
||||
if collapsed is None:
|
||||
collapsed = not self._collapsed
|
||||
if self._collapsed == collapsed:
|
||||
return
|
||||
self._collapsed = collapsed
|
||||
|
|
@ -182,6 +237,7 @@ class ExpandBtnLabel(QtWidgets.QLabel):
|
|||
else:
|
||||
self._current_image = self._source_expanded_pix
|
||||
self._set_resized_pix()
|
||||
self.state_changed.emit()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self._set_resized_pix()
|
||||
|
|
@ -203,21 +259,55 @@ class ExpandBtnLabel(QtWidgets.QLabel):
|
|||
|
||||
|
||||
class ExpandBtn(ClickableFrame):
|
||||
state_changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(ExpandBtn, self).__init__(parent)
|
||||
|
||||
pixmap_label = ExpandBtnLabel(self)
|
||||
pixmap_label = self._create_pix_widget(self)
|
||||
|
||||
layout = QtWidgets.QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(pixmap_label)
|
||||
|
||||
pixmap_label.state_changed.connect(self.state_changed)
|
||||
|
||||
self._pixmap_label = pixmap_label
|
||||
|
||||
def set_collapsed(self, collapsed):
|
||||
def _create_pix_widget(self, parent=None):
|
||||
if parent is None:
|
||||
parent = self
|
||||
return ExpandBtnLabel(parent)
|
||||
|
||||
@property
|
||||
def collapsed(self):
|
||||
return self._pixmap_label.collapsed
|
||||
|
||||
def set_collapsed(self, collapsed=None):
|
||||
self._pixmap_label.set_collapsed(collapsed)
|
||||
|
||||
|
||||
class ClassicExpandBtnLabel(ExpandBtnLabel):
|
||||
def _create_collapsed_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("right_arrow")
|
||||
)
|
||||
|
||||
def _create_expanded_pixmap(self):
|
||||
return QtGui.QPixmap(
|
||||
get_style_image_path("down_arrow")
|
||||
)
|
||||
|
||||
|
||||
class ClassicExpandBtn(ExpandBtn):
|
||||
"""Same as 'ExpandBtn' but with arrow images."""
|
||||
|
||||
def _create_pix_widget(self, parent=None):
|
||||
if parent is None:
|
||||
parent = self
|
||||
return ClassicExpandBtnLabel(parent)
|
||||
|
||||
|
||||
class ImageButton(QtWidgets.QPushButton):
|
||||
"""PushButton with icon and size of font.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue