Merge remote-tracking branch 'origin/bugfix/OP-4183_Crashed-save-cause-crash-of-UI' into enhancement/OP-3075_houdini-new-publisher

This commit is contained in:
Ondřej Samohel 2022-10-24 18:03:34 +02:00
commit d5afbcd005
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
9 changed files with 600 additions and 169 deletions

View file

@ -1,6 +1,8 @@
import os
import sys
import copy
import logging
import traceback
import collections
import inspect
from uuid import uuid4
@ -22,6 +24,7 @@ from .creator_plugins import (
Creator,
AutoCreator,
discover_creator_plugins,
CreatorError,
)
UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"])
@ -67,6 +70,77 @@ class HostMissRequiredMethod(Exception):
super(HostMissRequiredMethod, self).__init__(msg)
class CreatorsOperationFailed(Exception):
"""Raised when a creator process crashes in 'CreateContext'.
The exception contains information about the creator and error. The data
are prepared using 'prepare_failed_creator_operation_info' and can be
serialized using json.
Usage is for UI purposes which may not have access to exceptions directly
and would not have ability to catch exceptions 'per creator'.
Args:
msg (str): General error message.
failed_info (list[dict[str, Any]]): List of failed creators with
exception message and optionally formatted traceback.
"""
def __init__(self, msg, failed_info):
super(CreatorsOperationFailed, self).__init__(msg)
self.failed_info = failed_info
class CreatorsCollectionFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to collect instances"
super(CreatorsCollectionFailed, self).__init__(
msg, failed_info
)
class CreatorsSaveFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed update instance changes"
super(CreatorsSaveFailed, self).__init__(
msg, failed_info
)
class CreatorsRemoveFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Failed to remove instances"
super(CreatorsRemoveFailed, self).__init__(
msg, failed_info
)
class CreatorsCreateFailed(CreatorsOperationFailed):
def __init__(self, failed_info):
msg = "Faled to create instances"
super(CreatorsCreateFailed, self).__init__(
msg, failed_info
)
def prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback=True
):
formatted_traceback = None
exc_type, exc_value, exc_traceback = exc_info
if add_traceback:
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
return {
"creator_identifier": identifier,
"creator_label": label,
"message": str(exc_value),
"traceback": formatted_traceback
}
class InstanceMember:
"""Representation of instance member.
@ -1212,7 +1286,65 @@ class CreateContext:
with self.bulk_instances_collection():
self._bulk_instances_to_process.append(instance)
def create(self, identifier, *args, **kwargs):
"""Wrapper for creators to trigger created.
Different types of creators may expect different arguments thus the
hints for args are blind.
Args:
identifier (str): Creator's identifier.
*args (Tuple[Any]): Arguments for create method.
**kwargs (Dict[Any, Any]): Keyword argument for create method.
"""
error_message = "Failed to run Creator with identifier \"{}\". {}"
creator = self.creators.get(identifier)
label = getattr(creator, "label", None)
failed = False
add_traceback = False
exc_info = None
try:
# Fake CreatorError (Could be maybe specific exception?)
if creator is None:
raise CreatorError(
"Creator {} was not found".format(identifier)
)
creator.create(*args, **kwargs)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
raise CreatorsCreateFailed([
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
])
def creator_removed_instance(self, instance):
"""When creator removes instance context should be acknowledged.
If creator removes instance conext should know about it to avoid
possible issues in the session.
Args:
instance (CreatedInstance): Object of instance which was removed
from scene metadata.
"""
self._instances_by_id.pop(instance.id, None)
@contextmanager
@ -1247,24 +1379,81 @@ class CreateContext:
self._instances_by_id = {}
# Collect instances
error_message = "Collection of instances for creator {} failed. {}"
failed_info = []
for creator in self.creators.values():
creator.collect_instances()
label = creator.label
identifier = creator.identifier
failed = False
add_traceback = False
exc_info = None
try:
creator.collect_instances()
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsCollectionFailed(failed_info)
def execute_autocreators(self):
"""Execute discovered AutoCreator plugins.
Reset instances if any autocreator executed properly.
"""
error_message = "Failed to run AutoCreator with identifier \"{}\". {}"
failed_info = []
for identifier, creator in self.autocreators.items():
label = creator.label
failed = False
add_traceback = False
try:
creator.create()
except Exception:
# TODO raise report exception if any crashed
msg = (
"Failed to run AutoCreator with identifier \"{}\" ({})."
).format(identifier, inspect.getfile(creator.__class__))
self.log.warning(msg, exc_info=True)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
# Use bare except because some hosts raise their exceptions that
# do not inherit from python's `BaseException`
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsCreateFailed(failed_info)
def validate_instances_context(self, instances=None):
"""Validate 'asset' and 'task' instance context."""
@ -1341,17 +1530,48 @@ class CreateContext:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
for identifier, cretor_instances in instances_by_identifier.items():
error_message = "Instances update of creator \"{}\" failed. {}"
failed_info = []
for identifier, creator_instances in instances_by_identifier.items():
update_list = []
for instance in cretor_instances:
for instance in creator_instances:
instance_changes = instance.changes()
if instance_changes:
update_list.append(UpdateData(instance, instance_changes))
creator = self.creators[identifier]
if update_list:
if not update_list:
continue
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.update_instances(update_list)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(error_message.format(identifier, exc_info[1]))
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""), exc_info=True)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsSaveFailed(failed_info)
def remove_instances(self, instances):
"""Remove instances from context.
@ -1359,14 +1579,48 @@ class CreateContext:
instances(list<CreatedInstance>): Instances that should be removed
from context.
"""
instances_by_identifier = collections.defaultdict(list)
for instance in instances:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
for identifier, creator_instances in instances_by_identifier.items():
creator = self.creators.get(identifier)
creator.remove_instances(creator_instances)
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except:
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
if failed_info:
raise CreatorsRemoveFailed(failed_info)
def _get_publish_plugins_with_attr_for_family(self, family):
"""Publish plugin attributes for passed family.

View file

@ -31,6 +31,9 @@ from openpype.pipeline.create import (
HiddenCreator,
Creator,
)
from openpype.pipeline.create.context import (
CreatorsOperationFailed,
)
# Define constant for plugin orders offset
PLUGIN_ORDER_OFFSET = 0.5
@ -299,8 +302,11 @@ class PublishReport:
}
def _extract_context_data(self, context):
context_label = "Context"
if context is not None:
context_label = context.data.get("label")
return {
"label": context.data.get("label")
"label": context_label
}
def _extract_instance_data(self, instance, exists):
@ -1101,6 +1107,8 @@ class AbstractPublisherController(object):
options (Dict[str, Any]): Data from pre-create attributes.
"""
pass
def save_changes(self):
"""Save changes in create context."""
@ -1662,11 +1670,8 @@ class PublisherController(BasePublisherController):
def reset(self):
"""Reset everything related to creation and publishing."""
# Stop publishing
self.stop_publish()
self.save_changes()
self.host_is_valid = self._create_context.host_is_valid
self._create_context.reset_preparation()
@ -1715,8 +1720,28 @@ class PublisherController(BasePublisherController):
self._create_context.reset_context_data()
with self._create_context.bulk_instances_collection():
self._create_context.reset_instances()
self._create_context.execute_autocreators()
try:
self._create_context.reset_instances()
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.collection.failed",
{
"title": "Instance collection failed",
"failed_info": exc.failed_info
}
)
try:
self._create_context.execute_autocreators()
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.create.failed",
{
"title": "AutoCreation failed",
"failed_info": exc.failed_info
}
)
self._resetting_instances = False
@ -1845,16 +1870,42 @@ class PublisherController(BasePublisherController):
self, creator_identifier, subset_name, instance_data, options
):
"""Trigger creation and refresh of instances in UI."""
creator = self._creators[creator_identifier]
creator.create(subset_name, instance_data, options)
success = True
try:
self._create_context.create(
creator_identifier, subset_name, instance_data, options
)
except CreatorsOperationFailed as exc:
success = False
self._emit_event(
"instances.create.failed",
{
"title": "Creation failed",
"failed_info": exc.failed_info
}
)
self._on_create_instance_change()
return success
def save_changes(self):
"""Save changes happened during creation."""
if self._create_context.host_is_valid:
if not self._create_context.host_is_valid:
return
try:
self._create_context.save_changes()
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.save.failed",
{
"title": "Instances save failed",
"failed_info": exc.failed_info
}
)
def remove_instances(self, instance_ids):
"""Remove instances based on instance ids.
@ -1876,7 +1927,16 @@ class PublisherController(BasePublisherController):
instances_by_id[instance_id]
for instance_id in instance_ids
]
self._create_context.remove_instances(instances)
try:
self._create_context.remove_instances(instances)
except CreatorsOperationFailed as exc:
self._emit_event(
"instances.remove.failed",
{
"title": "Instance removement failed",
"failed_info": exc.failed_info
}
)
def _on_create_instance_change(self):
self._emit_event("instances.refresh.finished")

View file

@ -9,7 +9,6 @@ from openpype.pipeline.create import (
SUBSET_NAME_ALLOWED_SYMBOLS,
TaskNotSetError,
)
from openpype.tools.utils import ErrorMessageBox
from .widgets import (
IconValuePixmapLabel,
@ -35,79 +34,6 @@ class VariantInputsWidget(QtWidgets.QWidget):
self.resized.emit()
class CreateErrorMessageBox(ErrorMessageBox):
def __init__(
self,
creator_label,
subset_name,
asset_name,
exc_msg,
formatted_traceback,
parent
):
self._creator_label = creator_label
self._subset_name = subset_name
self._asset_name = asset_name
self._exc_msg = exc_msg
self._formatted_traceback = formatted_traceback
super(CreateErrorMessageBox, self).__init__("Creation failed", parent)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
label_widget.setText(
"<span style='font-size:18pt;'>Failed to create</span>"
)
return label_widget
def _get_report_data(self):
report_message = (
"{creator}: Failed to create Subset: \"{subset}\""
" in Asset: \"{asset}\""
"\n\nError: {message}"
).format(
creator=self._creator_label,
subset=self._subset_name,
asset=self._asset_name,
message=self._exc_msg,
)
if self._formatted_traceback:
report_message += "\n\n{}".format(self._formatted_traceback)
return [report_message]
def _create_content(self, content_layout):
item_name_template = (
"<span style='font-weight:bold;'>Creator:</span> {}<br>"
"<span style='font-weight:bold;'>Subset:</span> {}<br>"
"<span style='font-weight:bold;'>Asset:</span> {}<br>"
)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
line = self._create_line()
content_layout.addWidget(line)
item_name_widget = QtWidgets.QLabel(self)
item_name_widget.setText(
item_name_template.format(
self._creator_label, self._subset_name, self._asset_name
)
)
content_layout.addWidget(item_name_widget)
message_label_widget = QtWidgets.QLabel(self)
message_label_widget.setText(
exc_msg_template.format(self.convert_text_for_html(self._exc_msg))
)
content_layout.addWidget(message_label_widget)
if self._formatted_traceback:
line_widget = self._create_line()
tb_widget = self._create_traceback_widget(
self._formatted_traceback
)
content_layout.addWidget(line_widget)
content_layout.addWidget(tb_widget)
# TODO add creator identifier/label to details
class CreatorShortDescWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
@ -178,8 +104,6 @@ class CreateWidget(QtWidgets.QWidget):
self._prereq_available = False
self._message_dialog = None
name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS)
self._name_pattern = name_pattern
self._compiled_name_pattern = re.compile(name_pattern)
@ -769,7 +693,6 @@ class CreateWidget(QtWidgets.QWidget):
return
index = indexes[0]
creator_label = index.data(QtCore.Qt.DisplayRole)
creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE)
family = index.data(FAMILY_ROLE)
variant = self.variant_input.text()
@ -792,40 +715,13 @@ class CreateWidget(QtWidgets.QWidget):
"family": family
}
error_msg = None
formatted_traceback = None
try:
self._controller.create(
creator_identifier,
subset_name,
instance_data,
pre_create_data
)
success = self._controller.create(
creator_identifier,
subset_name,
instance_data,
pre_create_data
)
except CreatorError as exc:
error_msg = str(exc)
# Use bare except because some hosts raise their exceptions that
# do not inherit from python's `BaseException`
except:
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
error_msg = str(exc_value)
if error_msg is None:
if success:
self._set_creator(self._selected_creator)
self._controller.emit_card_message("Creation finished...")
else:
box = CreateErrorMessageBox(
creator_label,
subset_name,
asset_name,
error_msg,
formatted_traceback,
parent=self
)
box.show()
# Store dialog so is not garbage collected before is shown
self._message_dialog = box

View file

@ -93,8 +93,8 @@ class OverviewWidget(QtWidgets.QFrame):
main_layout.addWidget(subset_content_widget, 1)
change_anim = QtCore.QVariantAnimation()
change_anim.setStartValue(0)
change_anim.setEndValue(self.anim_end_value)
change_anim.setStartValue(float(0))
change_anim.setEndValue(float(self.anim_end_value))
change_anim.setDuration(self.anim_duration)
change_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad)
@ -264,9 +264,10 @@ class OverviewWidget(QtWidgets.QFrame):
+ (self._subset_content_layout.spacing() * 2)
)
)
subset_attrs_width = int(float(width) / self.anim_end_value) * value
subset_attrs_width = int((float(width) / self.anim_end_value) * value)
if subset_attrs_width > width:
subset_attrs_width = width
create_width = width - subset_attrs_width
self._create_widget.setMinimumWidth(create_width)

View file

@ -248,13 +248,13 @@ class PublishFrame(QtWidgets.QWidget):
hint = self._top_content_widget.minimumSizeHint()
end = hint.height()
self._shrunk_anim.setStartValue(start)
self._shrunk_anim.setEndValue(end)
self._shrunk_anim.setStartValue(float(start))
self._shrunk_anim.setEndValue(float(end))
if not anim_is_running:
self._shrunk_anim.start()
def _on_shrunk_anim(self, value):
diff = self._top_content_widget.height() - value
diff = self._top_content_widget.height() - int(value)
if not self._top_content_widget.isVisible():
diff -= self._content_layout.spacing()

View file

@ -1,3 +1,4 @@
import collections
from Qt import QtWidgets, QtCore, QtGui
from openpype import (
@ -5,6 +6,7 @@ from openpype import (
style
)
from openpype.tools.utils import (
ErrorMessageBox,
PlaceholderLineEdit,
MessageOverlayObject,
PixmapLabel,
@ -222,6 +224,12 @@ class PublisherWindow(QtWidgets.QDialog):
# Floating publish frame
publish_frame = PublishFrame(controller, self.footer_border, self)
creators_dialog_message_timer = QtCore.QTimer()
creators_dialog_message_timer.setInterval(100)
creators_dialog_message_timer.timeout.connect(
self._on_creators_message_timeout
)
help_btn.clicked.connect(self._on_help_click)
tabs_widget.tab_changed.connect(self._on_tab_change)
overview_widget.active_changed.connect(
@ -259,6 +267,18 @@ class PublisherWindow(QtWidgets.QDialog):
controller.event_system.add_callback(
"show.card.message", self._on_overlay_message
)
controller.event_system.add_callback(
"instances.collection.failed", self._instance_collection_failed
)
controller.event_system.add_callback(
"instances.save.failed", self._instance_save_failed
)
controller.event_system.add_callback(
"instances.remove.failed", self._instance_remove_failed
)
controller.event_system.add_callback(
"instances.create.failed", self._instance_create_failed
)
# Store extra header widget for TrayPublisher
# - can be used to add additional widgets to header between context
@ -298,10 +318,16 @@ class PublisherWindow(QtWidgets.QDialog):
self._controller = controller
self._first_show = True
self._reset_on_show = reset_on_show
# This is a little bit confusing but 'reset_on_first_show' is too long
# forin init
self._reset_on_first_show = reset_on_show
self._reset_on_show = True
self._restart_timer = None
self._publish_frame_visible = None
self._creators_messages_to_show = collections.deque()
self._creators_dialog_message_timer = creators_dialog_message_timer
self._set_publish_visibility(False)
@property
@ -314,6 +340,18 @@ class PublisherWindow(QtWidgets.QDialog):
self._first_show = False
self._on_first_show()
if not self._reset_on_show:
return
self._reset_on_show = False
# Detach showing - give OS chance to draw the window
timer = QtCore.QTimer()
timer.setSingleShot(True)
timer.setInterval(1)
timer.timeout.connect(self._on_show_restart_timer)
self._restart_timer = timer
timer.start()
def resizeEvent(self, event):
super(PublisherWindow, self).resizeEvent(event)
self._update_publish_frame_rect()
@ -324,16 +362,7 @@ class PublisherWindow(QtWidgets.QDialog):
def _on_first_show(self):
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
if not self._reset_on_show:
return
# Detach showing - give OS chance to draw the window
timer = QtCore.QTimer()
timer.setSingleShot(True)
timer.setInterval(1)
timer.timeout.connect(self._on_show_restart_timer)
self._restart_timer = timer
timer.start()
self._reset_on_show = self._reset_on_first_show
def _on_show_restart_timer(self):
"""Callback for '_restart_timer' timer."""
@ -342,9 +371,13 @@ class PublisherWindow(QtWidgets.QDialog):
self.reset()
def closeEvent(self, event):
self._controller.save_changes()
self.save_changes()
self._reset_on_show = True
super(PublisherWindow, self).closeEvent(event)
def save_changes(self):
self._controller.save_changes()
def reset(self):
self._controller.reset()
@ -436,7 +469,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._update_publish_frame_rect()
def _on_reset_clicked(self):
self._controller.reset()
self.save_changes()
self.reset()
def _on_stop_clicked(self):
self._controller.stop_publish()
@ -472,7 +506,7 @@ class PublisherWindow(QtWidgets.QDialog):
self._update_publish_details_widget()
if (
not self._tabs_widget.is_current_tab("create")
or not self._tabs_widget.is_current_tab("publish")
and not self._tabs_widget.is_current_tab("publish")
):
self._tabs_widget.set_current_tab("publish")
@ -569,3 +603,129 @@ class PublisherWindow(QtWidgets.QDialog):
self._publish_frame.move(
0, window_size.height() - height
)
def add_message_dialog(self, title, failed_info):
self._creators_messages_to_show.append((title, failed_info))
self._creators_dialog_message_timer.start()
def _on_creators_message_timeout(self):
if not self._creators_messages_to_show:
self._creators_dialog_message_timer.stop()
return
item = self._creators_messages_to_show.popleft()
title, failed_info = item
dialog = CreatorsErrorMessageBox(title, failed_info, self)
dialog.exec_()
dialog.deleteLater()
def _instance_collection_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
def _instance_save_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
def _instance_remove_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
def _instance_create_failed(self, event):
self.add_message_dialog(event["title"], event["failed_info"])
class CreatorsErrorMessageBox(ErrorMessageBox):
def __init__(self, error_title, failed_info, parent):
self._failed_info = failed_info
self._info_with_id = [
# Id must be string when used in tab widget
{"id": str(idx), "info": info}
for idx, info in enumerate(failed_info)
]
self._widgets_by_id = {}
self._tabs_widget = None
self._stack_layout = None
super(CreatorsErrorMessageBox, self).__init__(error_title, parent)
layout = self.layout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
footer_layout = self._footer_widget.layout()
footer_layout.setContentsMargins(5, 5, 5, 5)
def _create_top_widget(self, parent_widget):
return None
def _get_report_data(self):
output = []
for info in self._failed_info:
creator_label = info["creator_label"]
creator_identifier = info["creator_identifier"]
report_message = "Creator:"
if creator_label:
report_message += " {} ({})".format(
creator_label, creator_identifier)
else:
report_message += " {}".format(creator_identifier)
report_message += "\n\nError: {}".format(info["message"])
formatted_traceback = info["traceback"]
if formatted_traceback:
report_message += "\n\n{}".format(formatted_traceback)
output.append(report_message)
return output
def _create_content(self, content_layout):
tabs_widget = PublisherTabsWidget(self)
stack_widget = QtWidgets.QFrame(self._content_widget)
stack_layout = QtWidgets.QStackedLayout(stack_widget)
first = True
for item in self._info_with_id:
item_id = item["id"]
info = item["info"]
message = info["message"]
formatted_traceback = info["traceback"]
creator_label = info["creator_label"]
creator_identifier = info["creator_identifier"]
if not creator_label:
creator_label = creator_identifier
msg_widget = QtWidgets.QWidget(stack_widget)
msg_layout = QtWidgets.QVBoxLayout(msg_widget)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
message_label_widget = QtWidgets.QLabel(msg_widget)
message_label_widget.setText(
exc_msg_template.format(self.convert_text_for_html(message))
)
msg_layout.addWidget(message_label_widget, 0)
if formatted_traceback:
line_widget = self._create_line(msg_widget)
tb_widget = self._create_traceback_widget(formatted_traceback)
msg_layout.addWidget(line_widget, 0)
msg_layout.addWidget(tb_widget, 0)
msg_layout.addStretch(1)
tabs_widget.add_tab(creator_label, item_id)
stack_layout.addWidget(msg_widget)
if first:
first = False
stack_layout.setCurrentWidget(msg_widget)
self._widgets_by_id[item_id] = msg_widget
content_layout.addWidget(tabs_widget, 0)
content_layout.addWidget(stack_widget, 1)
tabs_widget.tab_changed.connect(self._on_tab_change)
self._tabs_widget = tabs_widget
self._stack_layout = stack_layout
def _on_tab_change(self, old_identifier, identifier):
widget = self._widgets_by_id[identifier]
self._stack_layout.setCurrentWidget(widget)

View file

@ -7,6 +7,7 @@ from .widgets import (
ExpandBtn,
PixmapLabel,
IconButton,
SeparatorWidget,
)
from .views import DeselectableTreeView
from .error_dialog import ErrorMessageBox
@ -37,6 +38,7 @@ __all__ = (
"ExpandBtn",
"PixmapLabel",
"IconButton",
"SeparatorWidget",
"DeselectableTreeView",

View file

@ -1,9 +1,9 @@
from Qt import QtWidgets, QtCore
from .widgets import ClickableFrame, ExpandBtn
from .widgets import ClickableFrame, ExpandBtn, SeparatorWidget
def convert_text_for_html(text):
def escape_text_for_html(text):
return (
text
.replace("<", "&#60;")
@ -19,7 +19,7 @@ class TracebackWidget(QtWidgets.QWidget):
# Modify text to match html
# - add more replacements when needed
tb_text = convert_text_for_html(tb_text)
tb_text = escape_text_for_html(tb_text)
expand_btn = ExpandBtn(self)
clickable_frame = ClickableFrame(self)
@ -85,17 +85,20 @@ class ErrorMessageBox(QtWidgets.QDialog):
copy_report_btn = QtWidgets.QPushButton("Copy report", self)
ok_btn = QtWidgets.QPushButton("OK", self)
footer_layout = QtWidgets.QHBoxLayout()
footer_widget = QtWidgets.QWidget(self)
footer_layout = QtWidgets.QHBoxLayout(footer_widget)
footer_layout.setContentsMargins(0, 0, 0, 0)
footer_layout.addWidget(copy_report_btn, 0)
footer_layout.addStretch(1)
footer_layout.addWidget(ok_btn, 0)
bottom_line = self._create_line()
body_layout = QtWidgets.QVBoxLayout(self)
body_layout.addWidget(top_widget, 0)
body_layout.addWidget(content_scroll, 1)
body_layout.addWidget(bottom_line, 0)
body_layout.addLayout(footer_layout, 0)
main_layout = QtWidgets.QVBoxLayout(self)
if top_widget is not None:
main_layout.addWidget(top_widget, 0)
main_layout.addWidget(content_scroll, 1)
main_layout.addWidget(bottom_line, 0)
main_layout.addWidget(footer_widget, 0)
copy_report_btn.clicked.connect(self._on_copy_report)
ok_btn.clicked.connect(self._on_ok_clicked)
@ -106,11 +109,13 @@ class ErrorMessageBox(QtWidgets.QDialog):
if not report_data:
copy_report_btn.setVisible(False)
self._content_scroll = content_scroll
self._footer_widget = footer_widget
self._report_data = report_data
@staticmethod
def convert_text_for_html(text):
return convert_text_for_html(text)
return escape_text_for_html(text)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
@ -131,7 +136,8 @@ class ErrorMessageBox(QtWidgets.QDialog):
self.close()
def _on_copy_report(self):
report_text = (10 * "*").join(self._report_data)
sep = "\n{}\n".format(10 * "*")
report_text = sep.join(self._report_data)
mime_data = QtCore.QMimeData()
mime_data.setText(report_text)
@ -139,12 +145,10 @@ class ErrorMessageBox(QtWidgets.QDialog):
mime_data
)
def _create_line(self):
line = QtWidgets.QFrame(self)
line.setObjectName("Separator")
line.setMinimumHeight(2)
line.setMaximumHeight(2)
return line
def _create_line(self, parent=None):
if parent is None:
parent = self
return SeparatorWidget(2, parent=parent)
def _create_traceback_widget(self, traceback_text, parent=None):
if parent is None:

View file

@ -448,3 +448,57 @@ class OptionDialog(QtWidgets.QDialog):
def parse(self):
return self._options.copy()
class SeparatorWidget(QtWidgets.QFrame):
"""Prepared widget that can be used as separator with predefined color.
Args:
size (int): Size of separator (width or height).
orientation (Qt.Horizontal|Qt.Vertical): Orintation of widget.
parent (QtWidgets.QWidget): Parent widget.
"""
def __init__(self, size=2, orientation=QtCore.Qt.Horizontal, parent=None):
super(SeparatorWidget, self).__init__(parent)
self.setObjectName("Separator")
maximum_width = self.maximumWidth()
maximum_height = self.maximumHeight()
self._size = None
self._orientation = orientation
self._maximum_width = maximum_width
self._maximum_height = maximum_height
self.set_size(size)
def set_size(self, size):
if size == self._size:
return
if self._orientation == QtCore.Qt.Vertical:
self.setMinimumWidth(size)
self.setMaximumWidth(size)
else:
self.setMinimumHeight(size)
self.setMaximumHeight(size)
self._size = size
def set_orientation(self, orientation):
if self._orientation == orientation:
return
# Reset min/max sizes in opossite direction
if self._orientation == QtCore.Qt.Vertical:
self.setMinimumHeight(0)
self.setMaximumHeight(self._maximum_height)
else:
self.setMinimumWidth(0)
self.setMaximumWidth(self._maximum_width)
self._orientation = orientation
size = self._size
self._size = None
self.set_size(size)