From d8905703e42edcfeeb63b4d61086341493d00670 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Fri, 6 Mar 2026 13:01:47 -0600 Subject: [PATCH 1/3] Allow image on canvas to be coiped to the clipboard --- openmc_plotter/main_window.py | 15 +++++++++++++++ openmc_plotter/plotgui.py | 19 +++++++++++++++++++ tests/setup_test/test.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) 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..038fb9d 100644 --- a/openmc_plotter/plotgui.py +++ b/openmc_plotter/plotgui.py @@ -180,6 +180,24 @@ def saveImage(self, filename): filename += ".png" self.figure.savefig(filename, transparent=True) + def copyImageToClipboard(self): + """Copy the current canvas image to the clipboard.""" + self.draw() + width, height = self.get_width_height() + if width <= 0 or height <= 0: + return False + + image = QtGui.QImage(self.buffer_rgba(), + width, + height, + QtGui.QImage.Format_RGBA8888).copy() + pixmap = QtGui.QPixmap.fromImage(image) + if pixmap.isNull(): + return False + + QtGui.QGuiApplication.clipboard().setPixmap(pixmap) + return True + def getDataIndices(self, event): cv = self.model.currentView @@ -506,6 +524,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..079e86a 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,33 @@ def test_batch_image(tmpdir, qtbot): filecmp.cmp(orig / 'ref1.png', tmpdir / 'test1.png') mw.close() + +def test_copy_image_to_clipboard(tmpdir, monkeypatch): + orig = tmpdir.chdir() + QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + mw = MainWindow(model_path=orig) + _openmcReload(model_path=orig) + mw.loadGui() + + class FakeClipboard: + def __init__(self): + self.pixmap = None + + def setPixmap(self, pixmap): + self.pixmap = pixmap + + fake_clipboard = FakeClipboard() + monkeypatch.setattr(QtGui.QGuiApplication, + 'clipboard', + staticmethod(lambda: fake_clipboard)) + + try: + assert mw.waitForPlotIdle(60000) + assert mw.copyImageToClipboard() + finally: + orig.chdir() + + assert fake_clipboard.pixmap is not None + assert not fake_clipboard.pixmap.isNull() + + mw.close() From 21c4a52e3bf62b6df26194278026f40299b97920 Mon Sep 17 00:00:00 2001 From: Patrick Shriwise Date: Fri, 6 Mar 2026 14:26:44 -0600 Subject: [PATCH 2/3] Copy only the matplotlib image and make background transparent --- openmc_plotter/plotgui.py | 59 +++++++++++++++++++----- tests/setup_test/test.py | 35 ++++++++++++--- tests/test_plotgui_clipboard.py | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 tests/test_plotgui_clipboard.py diff --git a/openmc_plotter/plotgui.py b/openmc_plotter/plotgui.py index 038fb9d..82f65c6 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 @@ -182,22 +183,60 @@ def saveImage(self, filename): def copyImageToClipboard(self): """Copy the current canvas image to the clipboard.""" - self.draw() - width, height = self.get_width_height() - if width <= 0 or height <= 0: + image = self._export_plot_image() + if image is None: return False - image = QtGui.QImage(self.buffer_rgba(), - width, - height, - QtGui.QImage.Format_RGBA8888).copy() - pixmap = QtGui.QPixmap.fromImage(image) - if pixmap.isNull(): + clipboard = QtGui.QGuiApplication.clipboard() + if clipboard is None: return False - QtGui.QGuiApplication.clipboard().setPixmap(pixmap) + 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 diff --git a/tests/setup_test/test.py b/tests/setup_test/test.py index 079e86a..2782736 100644 --- a/tests/setup_test/test.py +++ b/tests/setup_test/test.py @@ -58,19 +58,21 @@ def test_batch_image(tmpdir, qtbot): mw.close() -def test_copy_image_to_clipboard(tmpdir, monkeypatch): +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.pixmap = None + self.image = None - def setPixmap(self, pixmap): - self.pixmap = pixmap + def setImage(self, image): + self.image = image fake_clipboard = FakeClipboard() monkeypatch.setattr(QtGui.QGuiApplication, @@ -79,11 +81,32 @@ def setPixmap(self, pixmap): try: assert mw.waitForPlotIdle(60000) + mw.model.currentView.domainVisible = False + mw.plotIm.updatePixmap() assert mw.copyImageToClipboard() finally: orig.chdir() - assert fake_clipboard.pixmap is not None - assert not fake_clipboard.pixmap.isNull() + 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 From 6ac648723b4d7fffbd79987c199327b7706d0404 Mon Sep 17 00:00:00 2001 From: Paul Romano Date: Sat, 7 Mar 2026 10:30:04 -0600 Subject: [PATCH 3/3] Formatting --- openmc_plotter/plotgui.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openmc_plotter/plotgui.py b/openmc_plotter/plotgui.py index 82f65c6..72cdfcb 100644 --- a/openmc_plotter/plotgui.py +++ b/openmc_plotter/plotgui.py @@ -201,9 +201,7 @@ def _export_plot_image(self): return None buffer = io.BytesIO() - self.figure.savefig(buffer, - format='png', - transparent=True) + self.figure.savefig(buffer, format='png', transparent=True) image = QtGui.QImage.fromData(buffer.getvalue(), 'PNG') if image.isNull(): return None