diff --git a/openmc_plotter/main_window.py b/openmc_plotter/main_window.py index 2ae8e20..b3554c4 100755 --- a/openmc_plotter/main_window.py +++ b/openmc_plotter/main_window.py @@ -172,6 +172,12 @@ def createMenuBar(self): save_image_connector = partial(self.saveImage, filename=None) self.saveImageAction.triggered.connect(save_image_connector) + self.copyImageAction = QAction("&Copy Image", self) + self.copyImageAction.setShortcut("Ctrl+Shift+C") + self.copyImageAction.setToolTip('Copy plot image to clipboard') + self.copyImageAction.setStatusTip('Copy plot image to clipboard') + self.copyImageAction.triggered.connect(self.copyImageToClipboard) + self.saveViewAction = QAction("Save &View...", self) self.saveViewAction.setShortcut(QtGui.QKeySequence.Save) self.saveViewAction.setStatusTip('Save current view settings') @@ -199,6 +205,7 @@ def createMenuBar(self): self.fileMenu = self.mainMenu.addMenu('&File') self.fileMenu.addAction(self.reloadModelAction) + self.fileMenu.addAction(self.copyImageAction) self.fileMenu.addAction(self.saveImageAction) self.fileMenu.addAction(self.exportDataAction) self.fileMenu.addSeparator() @@ -519,6 +526,14 @@ def saveImage(self, filename=None): self.plotIm.saveImage(filename) self.statusBar().showMessage('Plot Image Saved', 5000) + def copyImageToClipboard(self): + if self.plotIm.copyImageToClipboard(): + self.statusBar().showMessage('Plot Image Copied', 5000) + return True + + self.statusBar().showMessage('No Plot Image Available', 5000) + return False + def saveView(self): filename, ext = QFileDialog.getSaveFileName(self, "Save View Settings", diff --git a/openmc_plotter/plotgui.py b/openmc_plotter/plotgui.py index b04b1b6..72cdfcb 100644 --- a/openmc_plotter/plotgui.py +++ b/openmc_plotter/plotgui.py @@ -1,3 +1,4 @@ +import io from functools import partial from PySide6 import QtCore, QtGui @@ -180,6 +181,60 @@ def saveImage(self, filename): filename += ".png" self.figure.savefig(filename, transparent=True) + def copyImageToClipboard(self): + """Copy the current canvas image to the clipboard.""" + image = self._export_plot_image() + if image is None: + return False + + clipboard = QtGui.QGuiApplication.clipboard() + if clipboard is None: + return False + + clipboard.setImage(image) + return True + + def _export_plot_image(self): + self.draw() + width, height = self.get_width_height() + if width <= 0 or height <= 0: + return None + + buffer = io.BytesIO() + self.figure.savefig(buffer, format='png', transparent=True) + image = QtGui.QImage.fromData(buffer.getvalue(), 'PNG') + if image.isNull(): + return None + + crop_rect = self._visible_canvas_rect(image.width(), image.height()) + if crop_rect.isEmpty(): + return None + + return image.copy(crop_rect) + + def _visible_canvas_rect(self, image_width, image_height): + if self.width() <= 0 or self.height() <= 0: + return QtCore.QRect() + + if self.parent is None or not hasattr(self.parent, 'viewport'): + return QtCore.QRect(0, 0, image_width, image_height) + + viewport = self.parent.viewport() + visible_width = min(viewport.width(), self.width()) + visible_height = min(viewport.height(), self.height()) + x_offset = self.parent.horizontalScrollBar().value() + y_offset = self.parent.verticalScrollBar().value() + + scale_x = image_width / self.width() + scale_y = image_height / self.height() + + return QtCore.QRect(round(x_offset * scale_x), + round(y_offset * scale_y), + round(visible_width * scale_x), + round(visible_height * scale_y)).intersected( + QtCore.QRect(0, 0, image_width, image_height) + ) + def getDataIndices(self, event): cv = self.model.currentView @@ -506,6 +561,7 @@ def contextMenuEvent(self, event): olapColorAction.triggered.connect(connector) self.menu.addSeparator() + self.menu.addAction(self.main_window.copyImageAction) self.menu.addAction(self.main_window.saveImageAction) self.menu.addAction(self.main_window.saveViewAction) self.menu.addAction(self.main_window.openAction) diff --git a/tests/setup_test/test.py b/tests/setup_test/test.py index 974b040..2782736 100644 --- a/tests/setup_test/test.py +++ b/tests/setup_test/test.py @@ -2,6 +2,7 @@ import shutil import pytest +from PySide6 import QtGui, QtWidgets from openmc_plotter.main_window import MainWindow, _openmcReload @@ -56,3 +57,56 @@ def test_batch_image(tmpdir, qtbot): filecmp.cmp(orig / 'ref1.png', tmpdir / 'test1.png') mw.close() + +def test_copy_image_to_clipboard(tmpdir, monkeypatch, qtbot): + orig = tmpdir.chdir() + QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + mw = MainWindow(model_path=orig) + _openmcReload(model_path=orig) + mw.loadGui() + qtbot.addWidget(mw) + mw.show() + + class FakeClipboard: + def __init__(self): + self.image = None + + def setImage(self, image): + self.image = image + + fake_clipboard = FakeClipboard() + monkeypatch.setattr(QtGui.QGuiApplication, + 'clipboard', + staticmethod(lambda: fake_clipboard)) + + try: + assert mw.waitForPlotIdle(60000) + mw.model.currentView.domainVisible = False + mw.plotIm.updatePixmap() + assert mw.copyImageToClipboard() + finally: + orig.chdir() + + assert fake_clipboard.image is not None + assert not fake_clipboard.image.isNull() + assert fake_clipboard.image.hasAlphaChannel() + + canvas_width, canvas_height = mw.plotIm.get_width_height() + expected_width = round( + min(mw.frame.viewport().width(), mw.plotIm.width()) + * canvas_width + / mw.plotIm.width() + ) + expected_height = round( + min(mw.frame.viewport().height(), mw.plotIm.height()) + * canvas_height + / mw.plotIm.height() + ) + assert fake_clipboard.image.width() == pytest.approx(expected_width, abs=1) + assert fake_clipboard.image.height() == pytest.approx(expected_height, abs=1) + + center = fake_clipboard.image.pixelColor(fake_clipboard.image.width() // 2, + fake_clipboard.image.height() // 2) + assert center.alpha() == 0 + + mw.close() diff --git a/tests/test_plotgui_clipboard.py b/tests/test_plotgui_clipboard.py new file mode 100644 index 0000000..e65e593 --- /dev/null +++ b/tests/test_plotgui_clipboard.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace + +import numpy as np +import pytest +from PySide6 import QtGui, QtWidgets + +from openmc_plotter.plotgui import PlotImage + + +class FakeClipboard: + + def __init__(self): + self.image = None + + def setImage(self, image): + self.image = image + + +@pytest.fixture +def qapp(): + return QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + + +def test_copy_image_to_clipboard_crops_to_plot_and_preserves_alpha( + qapp, monkeypatch +): + scroll = QtWidgets.QScrollArea() + scroll.resize(220, 160) + main_window = SimpleNamespace( + logicalDpiX=lambda: 100, + zoom=100, + coord_label=SimpleNamespace(show=lambda: None, hide=lambda: None), + statusBar=lambda: SimpleNamespace(showMessage=lambda *args, **kwargs: None), + ) + plot = PlotImage(model=None, parent=scroll, main_window=main_window) + scroll.setWidget(plot) + plot.resize(400, 300) + plot.figure.clear() + plot.ax = plot.figure.subplots() + plot.ax.imshow(np.zeros((10, 10, 4))) + scroll.show() + qapp.processEvents() + scroll.horizontalScrollBar().setValue(40) + scroll.verticalScrollBar().setValue(30) + qapp.processEvents() + + fake_clipboard = FakeClipboard() + monkeypatch.setattr( + QtGui.QGuiApplication, + "clipboard", + staticmethod(lambda: fake_clipboard), + ) + + try: + assert plot.copyImageToClipboard() + finally: + plot.close() + scroll.close() + + assert fake_clipboard.image is not None + assert not fake_clipboard.image.isNull() + assert fake_clipboard.image.hasAlphaChannel() + + canvas_width, canvas_height = plot.get_width_height() + expected_width = round( + min(scroll.viewport().width(), plot.width()) * canvas_width / plot.width() + ) + expected_height = round( + min(scroll.viewport().height(), plot.height()) * canvas_height / plot.height() + ) + assert fake_clipboard.image.width() == pytest.approx(expected_width, abs=1) + assert fake_clipboard.image.height() == pytest.approx(expected_height, abs=1) + assert fake_clipboard.image.width() < canvas_width + assert fake_clipboard.image.height() < canvas_height + + center = fake_clipboard.image.pixelColor( + fake_clipboard.image.width() // 2, + fake_clipboard.image.height() // 2, + ) + assert center.alpha() == 0