Skip to content

Commit 86050ce

Browse files
Copy image to clipboard (#176)
Co-authored-by: Paul Romano <paul.k.romano@gmail.com>
1 parent 94d54e5 commit 86050ce

File tree

4 files changed

+205
-0
lines changed

4 files changed

+205
-0
lines changed

openmc_plotter/main_window.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ def createMenuBar(self):
175175
save_image_connector = partial(self.saveImage, filename=None)
176176
self.saveImageAction.triggered.connect(save_image_connector)
177177

178+
self.copyImageAction = QAction("&Copy Image", self)
179+
self.copyImageAction.setShortcut("Ctrl+Shift+C")
180+
self.copyImageAction.setToolTip('Copy plot image to clipboard')
181+
self.copyImageAction.setStatusTip('Copy plot image to clipboard')
182+
self.copyImageAction.triggered.connect(self.copyImageToClipboard)
183+
178184
self.saveViewAction = QAction("Save &View...", self)
179185
self.saveViewAction.setShortcut(QtGui.QKeySequence.Save)
180186
self.saveViewAction.setStatusTip('Save current view settings')
@@ -202,6 +208,7 @@ def createMenuBar(self):
202208

203209
self.fileMenu = self.mainMenu.addMenu('&File')
204210
self.fileMenu.addAction(self.reloadModelAction)
211+
self.fileMenu.addAction(self.copyImageAction)
205212
self.fileMenu.addAction(self.saveImageAction)
206213
self.fileMenu.addAction(self.exportDataAction)
207214
self.fileMenu.addSeparator()
@@ -522,6 +529,14 @@ def saveImage(self, filename=None):
522529
self.plotIm.saveImage(filename)
523530
self.statusBar().showMessage('Plot Image Saved', 5000)
524531

532+
def copyImageToClipboard(self):
533+
if self.plotIm.copyImageToClipboard():
534+
self.statusBar().showMessage('Plot Image Copied', 5000)
535+
return True
536+
537+
self.statusBar().showMessage('No Plot Image Available', 5000)
538+
return False
539+
525540
def saveView(self):
526541
filename, ext = QFileDialog.getSaveFileName(self,
527542
"Save View Settings",

openmc_plotter/plotgui.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io
12
from functools import partial
23

34
from PySide6 import QtCore, QtGui
@@ -180,6 +181,60 @@ def saveImage(self, filename):
180181
filename += ".png"
181182
self.figure.savefig(filename, transparent=True)
182183

184+
def copyImageToClipboard(self):
185+
"""Copy the current canvas image to the clipboard."""
186+
image = self._export_plot_image()
187+
if image is None:
188+
return False
189+
190+
clipboard = QtGui.QGuiApplication.clipboard()
191+
if clipboard is None:
192+
return False
193+
194+
clipboard.setImage(image)
195+
return True
196+
197+
def _export_plot_image(self):
198+
self.draw()
199+
width, height = self.get_width_height()
200+
if width <= 0 or height <= 0:
201+
return None
202+
203+
buffer = io.BytesIO()
204+
self.figure.savefig(buffer, format='png', transparent=True)
205+
image = QtGui.QImage.fromData(buffer.getvalue(), 'PNG')
206+
if image.isNull():
207+
return None
208+
209+
crop_rect = self._visible_canvas_rect(image.width(), image.height())
210+
if crop_rect.isEmpty():
211+
return None
212+
213+
return image.copy(crop_rect)
214+
215+
def _visible_canvas_rect(self, image_width, image_height):
216+
if self.width() <= 0 or self.height() <= 0:
217+
return QtCore.QRect()
218+
219+
if self.parent is None or not hasattr(self.parent, 'viewport'):
220+
return QtCore.QRect(0, 0, image_width, image_height)
221+
222+
viewport = self.parent.viewport()
223+
visible_width = min(viewport.width(), self.width())
224+
visible_height = min(viewport.height(), self.height())
225+
x_offset = self.parent.horizontalScrollBar().value()
226+
y_offset = self.parent.verticalScrollBar().value()
227+
228+
scale_x = image_width / self.width()
229+
scale_y = image_height / self.height()
230+
231+
return QtCore.QRect(round(x_offset * scale_x),
232+
round(y_offset * scale_y),
233+
round(visible_width * scale_x),
234+
round(visible_height * scale_y)).intersected(
235+
QtCore.QRect(0, 0, image_width, image_height)
236+
)
237+
183238
def getDataIndices(self, event):
184239
cv = self.model.currentView
185240

@@ -506,6 +561,7 @@ def contextMenuEvent(self, event):
506561
olapColorAction.triggered.connect(connector)
507562

508563
self.menu.addSeparator()
564+
self.menu.addAction(self.main_window.copyImageAction)
509565
self.menu.addAction(self.main_window.saveImageAction)
510566
self.menu.addAction(self.main_window.saveViewAction)
511567
self.menu.addAction(self.main_window.openAction)

tests/setup_test/test.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import shutil
33

44
import pytest
5+
from PySide6 import QtGui, QtWidgets
56

67
from openmc_plotter.main_window import MainWindow, _openmcReload
78

@@ -56,3 +57,56 @@ def test_batch_image(tmpdir, qtbot):
5657
filecmp.cmp(orig / 'ref1.png', tmpdir / 'test1.png')
5758

5859
mw.close()
60+
61+
def test_copy_image_to_clipboard(tmpdir, monkeypatch, qtbot):
62+
orig = tmpdir.chdir()
63+
QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
64+
mw = MainWindow(model_path=orig)
65+
_openmcReload(model_path=orig)
66+
mw.loadGui()
67+
qtbot.addWidget(mw)
68+
mw.show()
69+
70+
class FakeClipboard:
71+
def __init__(self):
72+
self.image = None
73+
74+
def setImage(self, image):
75+
self.image = image
76+
77+
fake_clipboard = FakeClipboard()
78+
monkeypatch.setattr(QtGui.QGuiApplication,
79+
'clipboard',
80+
staticmethod(lambda: fake_clipboard))
81+
82+
try:
83+
assert mw.waitForPlotIdle(60000)
84+
mw.model.currentView.domainVisible = False
85+
mw.plotIm.updatePixmap()
86+
assert mw.copyImageToClipboard()
87+
finally:
88+
orig.chdir()
89+
90+
assert fake_clipboard.image is not None
91+
assert not fake_clipboard.image.isNull()
92+
assert fake_clipboard.image.hasAlphaChannel()
93+
94+
canvas_width, canvas_height = mw.plotIm.get_width_height()
95+
expected_width = round(
96+
min(mw.frame.viewport().width(), mw.plotIm.width())
97+
* canvas_width
98+
/ mw.plotIm.width()
99+
)
100+
expected_height = round(
101+
min(mw.frame.viewport().height(), mw.plotIm.height())
102+
* canvas_height
103+
/ mw.plotIm.height()
104+
)
105+
assert fake_clipboard.image.width() == pytest.approx(expected_width, abs=1)
106+
assert fake_clipboard.image.height() == pytest.approx(expected_height, abs=1)
107+
108+
center = fake_clipboard.image.pixelColor(fake_clipboard.image.width() // 2,
109+
fake_clipboard.image.height() // 2)
110+
assert center.alpha() == 0
111+
112+
mw.close()

tests/test_plotgui_clipboard.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from types import SimpleNamespace
2+
3+
import numpy as np
4+
import pytest
5+
from PySide6 import QtGui, QtWidgets
6+
7+
from openmc_plotter.plotgui import PlotImage
8+
9+
10+
class FakeClipboard:
11+
12+
def __init__(self):
13+
self.image = None
14+
15+
def setImage(self, image):
16+
self.image = image
17+
18+
19+
@pytest.fixture
20+
def qapp():
21+
return QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
22+
23+
24+
def test_copy_image_to_clipboard_crops_to_plot_and_preserves_alpha(
25+
qapp, monkeypatch
26+
):
27+
scroll = QtWidgets.QScrollArea()
28+
scroll.resize(220, 160)
29+
main_window = SimpleNamespace(
30+
logicalDpiX=lambda: 100,
31+
zoom=100,
32+
coord_label=SimpleNamespace(show=lambda: None, hide=lambda: None),
33+
statusBar=lambda: SimpleNamespace(showMessage=lambda *args, **kwargs: None),
34+
)
35+
plot = PlotImage(model=None, parent=scroll, main_window=main_window)
36+
scroll.setWidget(plot)
37+
plot.resize(400, 300)
38+
plot.figure.clear()
39+
plot.ax = plot.figure.subplots()
40+
plot.ax.imshow(np.zeros((10, 10, 4)))
41+
scroll.show()
42+
qapp.processEvents()
43+
scroll.horizontalScrollBar().setValue(40)
44+
scroll.verticalScrollBar().setValue(30)
45+
qapp.processEvents()
46+
47+
fake_clipboard = FakeClipboard()
48+
monkeypatch.setattr(
49+
QtGui.QGuiApplication,
50+
"clipboard",
51+
staticmethod(lambda: fake_clipboard),
52+
)
53+
54+
try:
55+
assert plot.copyImageToClipboard()
56+
finally:
57+
plot.close()
58+
scroll.close()
59+
60+
assert fake_clipboard.image is not None
61+
assert not fake_clipboard.image.isNull()
62+
assert fake_clipboard.image.hasAlphaChannel()
63+
64+
canvas_width, canvas_height = plot.get_width_height()
65+
expected_width = round(
66+
min(scroll.viewport().width(), plot.width()) * canvas_width / plot.width()
67+
)
68+
expected_height = round(
69+
min(scroll.viewport().height(), plot.height()) * canvas_height / plot.height()
70+
)
71+
assert fake_clipboard.image.width() == pytest.approx(expected_width, abs=1)
72+
assert fake_clipboard.image.height() == pytest.approx(expected_height, abs=1)
73+
assert fake_clipboard.image.width() < canvas_width
74+
assert fake_clipboard.image.height() < canvas_height
75+
76+
center = fake_clipboard.image.pixelColor(
77+
fake_clipboard.image.width() // 2,
78+
fake_clipboard.image.height() // 2,
79+
)
80+
assert center.alpha() == 0

0 commit comments

Comments
 (0)