diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css
index 0857bc80c6..8578522c79 100644
--- a/client/ayon_core/style/style.css
+++ b/client/ayon_core/style/style.css
@@ -1245,6 +1245,15 @@ ValidationArtistMessage QLabel {
background: transparent;
}
+#PluginDetailsContent {
+ background: {color:bg-inputs};
+ border-radius: 0.2em;
+}
+#PluginDetailsContent #PluginLabel {
+ font-size: 14pt;
+ font-weight: bold;
+}
+
CreateNextPageOverlay {
font-size: 32pt;
}
diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py
index ef207bfb79..a973d47a11 100644
--- a/client/ayon_core/tools/publisher/models/publish.py
+++ b/client/ayon_core/tools/publisher/models/publish.py
@@ -172,7 +172,7 @@ class PublishReportMaker:
"crashed_file_paths": crashed_file_paths,
"id": uuid.uuid4().hex,
"created_at": now.isoformat(),
- "report_version": "1.0.1",
+ "report_version": "1.1.0",
}
def _add_plugin_data_item(self, plugin: pyblish.api.Plugin):
@@ -194,11 +194,23 @@ class PublishReportMaker:
if hasattr(plugin, "label"):
label = plugin.label
+ plugin_type = "instance" if plugin.__instanceEnabled__ else "context"
+ # Get docstring
+ # NOTE we do care only about docstring from the plugin so we can't
+ # use 'inspect.getdoc' which also looks for docstring in parent
+ # classes.
+ docstring = getattr(plugin, "__doc__", None)
+ if docstring:
+ docstring = inspect.cleandoc(docstring)
return {
"id": plugin.id,
"name": plugin.__name__,
"label": label,
"order": plugin.order,
+ "filepath": inspect.getfile(plugin),
+ "docstring": docstring,
+ "plugin_type": plugin_type,
+ "families": list(plugin.families),
"targets": list(plugin.targets),
"instances_data": [],
"actions_data": [],
diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py
index 206f999bac..a3c5a7a2fd 100644
--- a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py
+++ b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py
@@ -13,8 +13,16 @@ class PluginItem:
self.skipped = plugin_data["skipped"]
self.passed = plugin_data["passed"]
+ # Introduced in report '1.1.0'
+ self.docstring = plugin_data.get("docstring")
+ self.filepath = plugin_data.get("filepath")
+ self.plugin_type = plugin_data.get("plugin_type")
+ self.families = plugin_data.get("families")
+
errored = False
+ process_time = 0.0
for instance_data in plugin_data["instances_data"]:
+ process_time += instance_data["process_time"]
for log_item in instance_data["logs"]:
errored = log_item["type"] == "error"
if errored:
@@ -22,6 +30,7 @@ class PluginItem:
if errored:
break
+ self.process_time = process_time
self.errored = errored
@property
diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py
index 24c26baa70..5fa1c04dc0 100644
--- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py
+++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py
@@ -3,6 +3,8 @@ from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import (
NiceCheckbox,
+ ElideLabel,
+ SeparatorWidget,
IconButton,
paint_image_with_color,
)
@@ -28,33 +30,89 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2
IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3
-class PluginLoadReportModel(QtGui.QStandardItemModel):
- def set_report(self, report):
- parent = self.invisibleRootItem()
- parent.removeRows(0, parent.rowCount())
+def get_pretty_milliseconds(value):
+ if value < 1000:
+ return f"{value:.3f}ms"
+ value /= 1000
+ if value < 60:
+ return f"{value:.2f}s"
+ seconds = int(value % 60)
+ value /= 60
+ if value < 60:
+ return f"{value:.2f}m {seconds:.2f}s"
+ minutes = int(value % 60)
+ value /= 60
+ return f"{value:.2f}h {minutes:.2f}m"
+
+class PluginLoadReportModel(QtGui.QStandardItemModel):
+ def __init__(self):
+ super().__init__()
+ self._traceback_by_filepath = {}
+ self._items_by_filepath = {}
+ self._is_active = True
+ self._need_refresh = False
+
+ def set_active(self, is_active):
+ if self._is_active is is_active:
+ return
+ self._is_active = is_active
+ self._update_items()
+
+ def set_report(self, report):
+ self._need_refresh = True
if report is None:
+ self._traceback_by_filepath.clear()
+ self._update_items()
+ return
+
+ filepaths = set(report.crashed_plugin_paths.keys())
+ to_remove = set(self._traceback_by_filepath) - filepaths
+ for filepath in filepaths:
+ self._traceback_by_filepath[filepath] = (
+ report.crashed_plugin_paths[filepath]
+ )
+
+ for filepath in to_remove:
+ self._traceback_by_filepath.pop(filepath)
+ self._update_items()
+
+ def _update_items(self):
+ if not self._is_active or not self._need_refresh:
+ return
+ parent = self.invisibleRootItem()
+ if not self._traceback_by_filepath:
+ parent.removeRows(0, parent.rowCount())
return
new_items = []
new_items_by_filepath = {}
- for filepath in report.crashed_plugin_paths.keys():
+ to_remove = (
+ set(self._items_by_filepath) - set(self._traceback_by_filepath)
+ )
+ for filepath in self._traceback_by_filepath:
+ if filepath in self._items_by_filepath:
+ continue
item = QtGui.QStandardItem(filepath)
new_items.append(item)
new_items_by_filepath[filepath] = item
+ self._items_by_filepath[filepath] = item
- if not new_items:
- return
+ if new_items:
+ parent.appendRows(new_items)
- parent.appendRows(new_items)
for filepath, item in new_items_by_filepath.items():
- traceback_txt = report.crashed_plugin_paths[filepath]
+ traceback_txt = self._traceback_by_filepath[filepath]
detail_item = QtGui.QStandardItem()
detail_item.setData(filepath, FILEPATH_ROLE)
detail_item.setData(traceback_txt, TRACEBACK_ROLE)
detail_item.setData(True, IS_DETAIL_ITEM_ROLE)
item.appendRow(detail_item)
+ for filepath in to_remove:
+ item = self._items_by_filepath.pop(filepath)
+ parent.removeRow(item.row())
+
class DetailWidget(QtWidgets.QTextEdit):
def __init__(self, text, *args, **kwargs):
@@ -101,10 +159,12 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
self._model = model
self._widgets_by_filepath = {}
- def _on_expand(self, index):
- for row in range(self._model.rowCount(index)):
- child_index = self._model.index(row, index.column(), index)
- self._create_widget(child_index)
+ def set_active(self, is_active):
+ self._model.set_active(is_active)
+
+ def set_report(self, report):
+ self._widgets_by_filepath = {}
+ self._model.set_report(report)
def showEvent(self, event):
super().showEvent(event)
@@ -114,6 +174,11 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
super().resizeEvent(event)
self._update_widgets_size_hints()
+ def _on_expand(self, index):
+ for row in range(self._model.rowCount(index)):
+ child_index = self._model.index(row, index.column(), index)
+ self._create_widget(child_index)
+
def _update_widgets_size_hints(self):
for item in self._widgets_by_filepath.values():
widget, index = item
@@ -142,10 +207,6 @@ class PluginLoadReportWidget(QtWidgets.QWidget):
self._view.setIndexWidget(index, widget)
self._widgets_by_filepath[filepath] = (widget, index)
- def set_report(self, report):
- self._widgets_by_filepath = {}
- self._model.set_report(report)
-
class ZoomPlainText(QtWidgets.QPlainTextEdit):
min_point_size = 1.0
@@ -235,6 +296,8 @@ class DetailsWidget(QtWidgets.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(output_widget)
+ self._is_active = True
+ self._need_refresh = False
self._output_widget = output_widget
self._report_item = None
self._instance_filter = set()
@@ -243,21 +306,33 @@ class DetailsWidget(QtWidgets.QWidget):
def clear(self):
self._output_widget.setPlainText("")
+ def set_active(self, is_active):
+ if self._is_active is is_active:
+ return
+ self._is_active = is_active
+ self._update_logs()
+
def set_report(self, report):
self._report_item = report
self._plugin_filter = set()
self._instance_filter = set()
+ self._need_refresh = True
self._update_logs()
def set_plugin_filter(self, plugin_filter):
self._plugin_filter = plugin_filter
+ self._need_refresh = True
self._update_logs()
def set_instance_filter(self, instance_filter):
self._instance_filter = instance_filter
+ self._need_refresh = True
self._update_logs()
def _update_logs(self):
+ if not self._is_active or not self._need_refresh:
+ return
+
if not self._report_item:
self._output_widget.setPlainText("")
return
@@ -300,6 +375,242 @@ class DetailsWidget(QtWidgets.QWidget):
self._output_widget.setPlainText(text)
+class PluginDetailsWidget(QtWidgets.QWidget):
+ def __init__(self, plugin_item, parent):
+ super().__init__(parent)
+
+ content_widget = QtWidgets.QFrame(self)
+ content_widget.setObjectName("PluginDetailsContent")
+
+ plugin_label_widget = QtWidgets.QLabel(content_widget)
+ plugin_label_widget.setObjectName("PluginLabel")
+
+ plugin_doc_widget = QtWidgets.QLabel(content_widget)
+ plugin_doc_widget.setWordWrap(True)
+
+ form_separator = SeparatorWidget(parent=content_widget)
+
+ plugin_class_label = QtWidgets.QLabel("Class:")
+ plugin_class_widget = QtWidgets.QLabel(content_widget)
+
+ plugin_order_label = QtWidgets.QLabel("Order:")
+ plugin_order_widget = QtWidgets.QLabel(content_widget)
+
+ plugin_families_label = QtWidgets.QLabel("Families:")
+ plugin_families_widget = QtWidgets.QLabel(content_widget)
+ plugin_families_widget.setWordWrap(True)
+
+ plugin_path_label = QtWidgets.QLabel("File Path:")
+ plugin_path_widget = ElideLabel(content_widget)
+ plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft)
+
+ plugin_time_label = QtWidgets.QLabel("Time:")
+ plugin_time_widget = QtWidgets.QLabel(content_widget)
+
+ # Set interaction flags
+ for label_widget in (
+ plugin_label_widget,
+ plugin_doc_widget,
+ plugin_class_widget,
+ plugin_order_widget,
+ plugin_families_widget,
+ plugin_time_widget,
+ ):
+ label_widget.setTextInteractionFlags(
+ QtCore.Qt.TextBrowserInteraction
+ )
+
+ # Change style of form labels
+ for label_widget in (
+ plugin_class_label,
+ plugin_order_label,
+ plugin_families_label,
+ plugin_path_label,
+ plugin_time_label,
+ ):
+ label_widget.setObjectName("PluginFormLabel")
+
+ plugin_label = plugin_item.label or plugin_item.name
+ if plugin_item.plugin_type:
+ plugin_label += " ({})".format(
+ plugin_item.plugin_type.capitalize()
+ )
+
+ time_label = "Not started"
+ if plugin_item.passed:
+ time_label = get_pretty_milliseconds(plugin_item.process_time)
+ elif plugin_item.skipped:
+ time_label = "Skipped plugin"
+
+ families = "N/A"
+ if plugin_item.families:
+ families = ", ".join(plugin_item.families)
+
+ order = "N/A"
+ if plugin_item.order is not None:
+ order = str(plugin_item.order)
+
+ plugin_label_widget.setText(plugin_label)
+ plugin_doc_widget.setText(plugin_item.docstring or "N/A")
+ plugin_class_widget.setText(plugin_item.name or "N/A")
+ plugin_order_widget.setText(order)
+ plugin_families_widget.setText(families)
+ plugin_path_widget.setText(plugin_item.filepath or "N/A")
+ plugin_path_widget.setToolTip(plugin_item.filepath or None)
+ plugin_time_widget.setText(time_label)
+
+ content_layout = QtWidgets.QGridLayout(content_widget)
+ content_layout.setContentsMargins(8, 8, 8, 8)
+ content_layout.setColumnStretch(0, 0)
+ content_layout.setColumnStretch(1, 1)
+ row = 0
+
+ content_layout.addWidget(plugin_label_widget, row, 0, 1, 2)
+ row += 1
+
+ # Hide docstring if it is empty
+ if plugin_item.docstring:
+ content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2)
+ row += 1
+ else:
+ plugin_doc_widget.setVisible(False)
+
+ content_layout.addWidget(form_separator, row, 0, 1, 2)
+ row += 1
+
+ for label_widget, value_widget in (
+ (plugin_class_label, plugin_class_widget),
+ (plugin_order_label, plugin_order_widget),
+ (plugin_families_label, plugin_families_widget),
+ (plugin_path_label, plugin_path_widget),
+ (plugin_time_label, plugin_time_widget),
+ ):
+ content_layout.addWidget(label_widget, row, 0)
+ content_layout.addWidget(value_widget, row, 1)
+ row += 1
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(content_widget, 0)
+
+
+class PluginsDetailsWidget(QtWidgets.QWidget):
+ def __init__(self, parent):
+ super().__init__(parent)
+
+ scroll_area = QtWidgets.QScrollArea(self)
+ scroll_area.setWidgetResizable(True)
+
+ scroll_content_widget = QtWidgets.QWidget(scroll_area)
+
+ scroll_area.setWidget(scroll_content_widget)
+
+ empty_label = QtWidgets.QLabel(
+ "
Select plugins to view more information...",
+ scroll_content_widget
+ )
+ empty_label.setAlignment(QtCore.Qt.AlignCenter)
+
+ content_widget = QtWidgets.QWidget(scroll_content_widget)
+
+ content_layout = QtWidgets.QVBoxLayout(content_widget)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(10)
+
+ scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget)
+ scroll_content_layout.setContentsMargins(0, 0, 0, 0)
+ scroll_content_layout.addWidget(empty_label, 0)
+ scroll_content_layout.addWidget(content_widget, 0)
+ scroll_content_layout.addStretch(1)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(scroll_area, 1)
+
+ content_widget.setVisible(False)
+
+ self._scroll_area = scroll_area
+ self._empty_label = empty_label
+ self._content_layout = content_layout
+ self._content_widget = content_widget
+
+ self._widgets_by_plugin_id = {}
+ self._stretch_item_index = 0
+
+ self._is_active = True
+ self._need_refresh = False
+
+ self._report_item = None
+ self._plugin_filter = set()
+ self._plugin_ids = None
+
+ def set_active(self, is_active):
+ if self._is_active is is_active:
+ return
+ self._is_active = is_active
+ self._update_widgets()
+
+ def set_plugin_filter(self, plugin_filter):
+ self._need_refresh = True
+ self._plugin_filter = plugin_filter
+ self._update_widgets()
+
+ def set_report(self, report):
+ self._plugin_ids = None
+ self._plugin_filter = set()
+ self._need_refresh = True
+ self._report_item = report
+ self._update_widgets()
+
+ def _get_plugin_ids(self):
+ if self._plugin_ids is not None:
+ return self._plugin_ids
+
+ # Clear layout and clear widgets
+ while self._content_layout.count():
+ item = self._content_layout.takeAt(0)
+ widget = item.widget()
+ if widget:
+ widget.setVisible(False)
+ widget.deleteLater()
+
+ self._widgets_by_plugin_id.clear()
+
+ plugin_ids = []
+ if self._report_item is not None:
+ plugin_ids = list(self._report_item.plugins_id_order)
+ self._plugin_ids = plugin_ids
+ return plugin_ids
+
+ def _update_widgets(self):
+ if not self._is_active or not self._need_refresh:
+ return
+
+ self._need_refresh = False
+
+ # Hide content widget before updating
+ # - add widgets to layout can happen without recalculating
+ # the layout and widget size hints
+ self._content_widget.setVisible(False)
+
+ any_visible = False
+ for plugin_id in self._get_plugin_ids():
+ widget = self._widgets_by_plugin_id.get(plugin_id)
+ if widget is None:
+ plugin_item = self._report_item.plugins_items_by_id[plugin_id]
+ widget = PluginDetailsWidget(plugin_item, self._content_widget)
+ self._widgets_by_plugin_id[plugin_id] = widget
+ self._content_layout.addWidget(widget, 0)
+
+ is_visible = plugin_id in self._plugin_filter
+ widget.setVisible(is_visible)
+ if is_visible:
+ any_visible = True
+
+ self._content_widget.setVisible(any_visible)
+ self._empty_label.setVisible(not any_visible)
+
+
class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
@@ -446,9 +757,16 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
logs_text_widget = DetailsWidget(details_tab_widget)
plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget)
+ plugins_details_widget = PluginsDetailsWidget(details_tab_widget)
+
+ plugin_load_report_widget.set_active(False)
+ plugins_details_widget.set_active(False)
details_tab_widget.addTab(logs_text_widget, "Logs")
- details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins")
+ details_tab_widget.addTab(plugins_details_widget, "Plugins Details")
+ details_tab_widget.addTab(
+ plugin_load_report_widget, "Crashed plugins"
+ )
middle_widget = QtWidgets.QWidget(self)
middle_layout = QtWidgets.QGridLayout(middle_widget)
@@ -465,6 +783,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
layout.addWidget(middle_widget, 0)
layout.addWidget(details_widget, 1)
+ details_tab_widget.currentChanged.connect(self._on_tab_change)
instances_view.selectionModel().selectionChanged.connect(
self._on_instance_change
)
@@ -483,10 +802,12 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
details_popup_btn.clicked.connect(self._on_details_popup)
details_popup.closed.connect(self._on_popup_close)
+ self._current_tab_idx = 0
self._ignore_selection_changes = False
self._report_item = None
self._logs_text_widget = logs_text_widget
self._plugin_load_report_widget = plugin_load_report_widget
+ self._plugins_details_widget = plugins_details_widget
self._removed_instances_check = removed_instances_check
self._instances_view = instances_view
@@ -523,6 +844,14 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
else:
self._plugins_view.expand(index)
+ def set_active(self, active):
+ for idx in range(self._details_tab_widget.count()):
+ widget = self._details_tab_widget.widget(idx)
+ widget.set_active(active and idx == self._current_tab_idx)
+
+ if not active:
+ self.close_details_popup()
+
def set_report_data(self, report_data):
report = PublishReport(report_data)
self.set_report(report)
@@ -536,12 +865,22 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
self._plugins_model.set_report(report)
self._logs_text_widget.set_report(report)
self._plugin_load_report_widget.set_report(report)
+ self._plugins_details_widget.set_report(report)
self._ignore_selection_changes = False
self._instances_view.expandAll()
self._plugins_view.expandAll()
+ def _on_tab_change(self, new_idx):
+ if self._current_tab_idx == new_idx:
+ return
+ old_widget = self._details_tab_widget.widget(self._current_tab_idx)
+ new_widget = self._details_tab_widget.widget(new_idx)
+ self._current_tab_idx = new_idx
+ old_widget.set_active(False)
+ new_widget.set_active(True)
+
def _on_instance_change(self, *_args):
if self._ignore_selection_changes:
return
@@ -563,6 +902,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame):
plugin_ids.add(index.data(ITEM_ID_ROLE))
self._logs_text_widget.set_plugin_filter(plugin_ids)
+ self._plugins_details_widget.set_plugin_filter(plugin_ids)
def _on_skipped_plugin_check(self):
self._plugins_proxy.set_ignore_skipped(
diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py
index 1218221420..0c6087b41d 100644
--- a/client/ayon_core/tools/publisher/window.py
+++ b/client/ayon_core/tools/publisher/window.py
@@ -687,13 +687,14 @@ class PublisherWindow(QtWidgets.QDialog):
def _on_tab_change(self, old_tab, new_tab):
if old_tab == "details":
- self._publish_details_widget.close_details_popup()
+ self._publish_details_widget.set_active(False)
if new_tab == "details":
self._content_stacked_layout.setCurrentWidget(
self._publish_details_widget
)
self._update_publish_details_widget()
+ self._publish_details_widget.set_active(True)
elif new_tab == "report":
self._content_stacked_layout.setCurrentWidget(
diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py
index 059ac28b7b..3e265c7692 100644
--- a/client/ayon_core/tools/utils/__init__.py
+++ b/client/ayon_core/tools/utils/__init__.py
@@ -5,6 +5,7 @@ from .widgets import (
ComboBox,
CustomTextComboBox,
PlaceholderLineEdit,
+ ElideLabel,
HintedLineEdit,
ExpandingTextEdit,
BaseClickableFrame,
@@ -89,6 +90,7 @@ __all__ = (
"ComboBox",
"CustomTextComboBox",
"PlaceholderLineEdit",
+ "ElideLabel",
"HintedLineEdit",
"ExpandingTextEdit",
"BaseClickableFrame",
diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py
index 5e4cd75cfe..73c8819758 100644
--- a/client/ayon_core/tools/utils/widgets.py
+++ b/client/ayon_core/tools/utils/widgets.py
@@ -105,6 +105,102 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit):
self.setPalette(filter_palette)
+class ElideLabel(QtWidgets.QLabel):
+ """Label which elide text.
+
+ By default, elide happens on right side. Can be changed with
+ 'set_elide_mode' method.
+
+ It is not possible to use other features of QLabel like word wrap or
+ interactive text. This is a simple label which elide text.
+ """
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Preferred
+ )
+ # Store text set during init
+ self._text = self.text()
+ # Define initial elide mode
+ self._elide_mode = QtCore.Qt.ElideRight
+ # Make sure that text of QLabel is empty
+ super().setText("")
+
+ def setText(self, text):
+ # Update private text attribute and force update
+ self._text = text
+ self.update()
+
+ def setWordWrap(self, word_wrap):
+ # Word wrap is not supported in 'ElideLabel'
+ if word_wrap:
+ raise ValueError("Word wrap is not supported in 'ElideLabel'.")
+
+ def contextMenuEvent(self, event):
+ menu = self.create_context_menu(event.pos())
+ if menu is None:
+ event.ignore()
+ return
+ event.accept()
+ menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+ menu.popup(event.globalPos())
+
+ def create_context_menu(self, pos):
+ if not self._text:
+ return None
+ menu = QtWidgets.QMenu(self)
+
+ # Copy text action
+ copy_action = menu.addAction("Copy")
+ copy_action.setObjectName("edit-copy")
+ icon = QtGui.QIcon.fromTheme("edit-copy")
+ if not icon.isNull():
+ copy_action.setIcon(icon)
+
+ copy_action.triggered.connect(self._on_copy_text)
+ return menu
+
+ def set_set(self, text):
+ self.setText(text)
+
+ def set_elide_mode(self, elide_mode):
+ """Change elide type.
+
+ Args:
+ elide_mode: Possible elide type. Available in 'QtCore.Qt'
+ 'ElideLeft', 'ElideRight' and 'ElideMiddle'.
+
+ """
+ if elide_mode == QtCore.Qt.ElideNone:
+ raise ValueError(
+ "Invalid elide type. 'ElideNone' is not supported."
+ )
+
+ if elide_mode not in (
+ QtCore.Qt.ElideLeft,
+ QtCore.Qt.ElideRight,
+ QtCore.Qt.ElideMiddle,
+ ):
+ raise ValueError(f"Unknown value '{elide_mode}'")
+ self._elide_mode = elide_mode
+ self.update()
+
+ def paintEvent(self, event):
+ super().paintEvent(event)
+
+ painter = QtGui.QPainter(self)
+ fm = painter.fontMetrics()
+ elided_line = fm.elidedText(
+ self._text, self._elide_mode, self.width()
+ )
+ painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line)
+
+ def _on_copy_text(self):
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(self._text)
+
+
class _LocalCache:
down_arrow_icon = None