') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Copy image to clipboard by pshriwise · Pull Request #176 · openmc-dev/plotter · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions openmc_plotter/main_window.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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')
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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",
Expand Down
56 changes: 56 additions & 0 deletions openmc_plotter/plotgui.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import io
from functools import partial

from PySide6 import QtCore, QtGui
Expand DownExpand Up@@ -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

Expand DownExpand Up@@ -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)
Expand Down
54 changes: 54 additions & 0 deletions tests/setup_test/test.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@
import shutil

import pytest
from PySide6 import QtGui, QtWidgets

from openmc_plotter.main_window import MainWindow, _openmcReload

Expand DownExpand Up@@ -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()
80 changes: 80 additions & 0 deletions tests/test_plotgui_clipboard.py
Original file line numberDiff line numberDiff line change
@@ -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