diff --git a/setoolsgui/widgets/queryupdater.py b/setoolsgui/widgets/queryupdater.py
index 03d02a5..7cfcdc7 100644
--- a/setoolsgui/widgets/queryupdater.py
+++ b/setoolsgui/widgets/queryupdater.py
@@ -6,7 +6,8 @@
import logging
import typing
-from PyQt6 import QtCore
+import networkx as nx
+from PyQt6 import QtCore, QtGui, QtWidgets
import setools
from . import models
@@ -42,17 +43,19 @@ class QueryResultsUpdater(QtCore.QObject, typing.Generic[Q]):
finished = QtCore.pyqtSignal(int)
raw_line = QtCore.pyqtSignal(str)
- def __init__(self, query: Q,
- model: models.SEToolsTableModel | None = None,
+ def __init__(self, query: Q, /, *,
+ table_model: models.SEToolsTableModel | None = None,
+ graphics_buffer: QtWidgets.QLabel | None = None,
render: RenderFunction = lambda _, x: str(x),
result_limit: int = 0) -> None:
super().__init__()
self.log: typing.Final = logging.getLogger(query.__module__)
self.query: typing.Final[Q] = query
- self.model = model
+ self.table_model = table_model
self.render = render
self.result_limit = result_limit
+ self.graphics_buffer = graphics_buffer
def update(self) -> None:
"""Run the query and update results."""
@@ -83,8 +86,19 @@ class QueryResultsUpdater(QtCore.QObject, typing.Generic[Q]):
self.log.info(f"Generated {counter} total results.")
- if self.model:
- self.model.item_list = results
+ if self.table_model:
+ self.log.info("Updating results in table model.")
+ self.table_model.item_list = results
+
+ if self.graphics_buffer:
+ assert isinstance(self.query, setools.query.DirectedGraphAnalysis)
+ self.log.info("Generating graphical results.")
+ self.graphics_buffer.clear()
+ pgv = nx.nx_agraph.to_agraph(self.query.graphical_results())
+ pic = QtGui.QPixmap()
+ pic.loadFromData(pgv.draw(prog="dot", format="png"), "PNG")
+ self.graphics_buffer.setPixmap(pic)
+
self.finished.emit(counter)
except Exception as e:
diff --git a/setoolsgui/widgets/tab.py b/setoolsgui/widgets/tab.py
index 8802202..4c7ca44 100644
--- a/setoolsgui/widgets/tab.py
+++ b/setoolsgui/widgets/tab.py
@@ -8,7 +8,7 @@ import typing
from PyQt6 import QtCore, QtGui, QtWidgets
import setools
-from . import criteria, exception, models, views
+from . import criteria, exception, models, util, views
from .models.typing import QObjectType
from .queryupdater import QueryResultsUpdater
@@ -462,7 +462,7 @@ class TableResultTabWidget(BaseAnalysisTabWidget):
"""Set the table results model for this tab and set up the processing thread for it."""
self.sort_proxy.setSourceModel(model)
- self.worker = QueryResultsUpdater(self.query, model)
+ self.worker = QueryResultsUpdater(self.query, table_model=model)
self.worker.moveToThread(self.processing_thread)
self.worker.raw_line.connect(self.raw_results.appendPlainText)
self.worker.finished.connect(self.query_completed)
@@ -583,17 +583,27 @@ class DirectedGraphResultTab(BaseAnalysisTabWidget, typing.Generic[DGA]):
sizePolicy.setHeightForWidth(self.results.sizePolicy().hasHeightForWidth())
#
- # Create placeholder future graphical tab
+ # Create graphical tab
#
- self.graphical_results = QtWidgets.QWidget(self.results)
+ self.graphical_scroll = QtWidgets.QScrollArea(self.results)
+ self.graphical_scroll.setSizeAdjustPolicy(
+ QtWidgets.QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
+ self.graphical_scroll.setWidgetResizable(True)
+ self.results.addTab(self.graphical_scroll, "Graphical Results")
+
+ image_size_policy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding,
+ QtWidgets.QSizePolicy.Policy.MinimumExpanding)
+ image_size_policy.setHorizontalStretch(1)
+ image_size_policy.setVerticalStretch(1)
+ image_size_policy.setHeightForWidth(True)
+
+ self.graphical_results = QtWidgets.QLabel(self.graphical_scroll)
self.graphical_results.setObjectName("graphical_results")
- self.graphical_results.setSizePolicy(sizePolicy)
- self.results.addTab(self.graphical_results, "Graphical Results")
- self.results.setTabEnabled(DirectedGraphResultTab.ResultTab.Graph, False)
- self.results.setTabWhatsThis(DirectedGraphResultTab.ResultTab.Graph,
- "Future graphical results feature.")
- self.results.setTabToolTip(DirectedGraphResultTab.ResultTab.Graph,
- "Future graphical results feature.")
+ self.graphical_results.setSizePolicy(image_size_policy)
+ self.graphical_results.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
+ self.graphical_results.customContextMenuRequested.connect(
+ self._graphical_results_context_menu)
+ self.graphical_scroll.setWidget(self.graphical_results)
#
# Create tree browser tab
@@ -627,7 +637,7 @@ class DirectedGraphResultTab(BaseAnalysisTabWidget, typing.Generic[DGA]):
"This tab has plain text results of the query.")
# set initial tab
- self.results.setCurrentIndex(DirectedGraphResultTab.ResultTab.Tree)
+ self.results.setCurrentIndex(DirectedGraphResultTab.ResultTab.Graph)
# set up processing thread
self.processing_thread = QtCore.QThread(self.analysis_widget)
@@ -641,7 +651,7 @@ class DirectedGraphResultTab(BaseAnalysisTabWidget, typing.Generic[DGA]):
self.busy.reset()
# set up results worker
- self.worker = QueryResultsUpdater[DGA](self.query)
+ self.worker = QueryResultsUpdater[DGA](self.query, graphics_buffer=self.graphical_results)
self.worker.moveToThread(self.processing_thread)
self.worker.raw_line.connect(self.raw_results.appendPlainText)
self.worker.finished.connect(self.query_completed)
@@ -662,7 +672,33 @@ class DirectedGraphResultTab(BaseAnalysisTabWidget, typing.Generic[DGA]):
@tree_results_model.setter
def tree_results_model(self, model: models.SEToolsTableModel) -> None:
self.tree_results.setModel(model)
- self.worker.model = model
+ self.worker.table_model = model
+
+ def _graphical_results_context_menu(self, pos: QtCore.QPoint) -> None:
+ """Generate context menu for graphical results widget."""
+ save_action = QtGui.QAction("Save As...", self.graphical_results)
+ save_action.triggered.connect(self._save_graphical_results)
+
+ menu = QtWidgets.QMenu(self.graphical_results)
+ menu.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose)
+ menu.addActions((save_action,))
+ menu.exec(self.graphical_results.mapToGlobal(pos))
+
+ def _save_graphical_results(self) -> None:
+ """Save the graphical results to a file."""
+ with util.QMessageOnException("Error",
+ "Failed to save graphical results.",
+ log=self.log,
+ parent=self):
+
+ filename, _ = QtWidgets.QFileDialog.getSaveFileName(
+ self, "Save graphical results", "", "PNG files (*.png);;All files (*)")
+
+ if filename:
+ if not self.graphical_results.pixmap().save(filename, format="PNG"):
+ # The save method does not raise an exception, so unfortunately
+ # there is no additional info to share with the user.
+ raise RuntimeError(f"Failed to save graphical results to {filename}.")
#
# Start/end of processing
diff --git a/setoolsgui/widgets/util.py b/setoolsgui/widgets/util.py
new file mode 100644
index 0000000..74dfa7c
--- /dev/null
+++ b/setoolsgui/widgets/util.py
@@ -0,0 +1,53 @@
+# SPDX-License-Identifier: LGPL-2.1-only
+
+import logging
+import traceback
+import types
+import typing
+
+from PyQt6 import QtWidgets
+
+__all__: typing.Final[tuple[str, ...]] = ("QMessageOnException",)
+
+
+class QMessageOnException:
+
+ """Context manager to display a message box on exception."""
+
+ def __init__(self, title: str, message: str, /, *,
+ suppress: bool = True,
+ log: logging.Logger | None = None,
+ icon: QtWidgets.QMessageBox.Icon = QtWidgets.QMessageBox.Icon.Critical,
+ parent: QtWidgets.QWidget | None = None) -> None:
+
+ self.title: typing.Final[str] = title
+ self.message: typing.Final[str] = message
+ self.suppress: typing.Final[bool] = suppress
+ self.parent: typing.Final[QtWidgets.QWidget | None] = parent
+ self.log: typing.Final[logging.Logger] = log if log else logging.getLogger(__name__)
+ self.icon: typing.Final[QtWidgets.QMessageBox.Icon] = icon
+
+ def __enter__(self) -> None:
+ pass
+
+ def __exit__(self,
+ exc_type: type[BaseException] | None,
+ exc_value: BaseException | None,
+ tb: types.TracebackType | None) -> bool:
+
+ if exc_type:
+ self.log.critical(self.message)
+ self.log.debug("Backtrace", exc_info=True)
+
+ msg = QtWidgets.QMessageBox(self.icon,
+ self.title,
+ self.message,
+ parent=self.parent)
+
+ msg.setInformativeText(str(exc_value))
+ msg.setDetailedText("\n".join(traceback.format_tb(tb)))
+ msg.exec()
+
+ return self.suppress
+
+ return False