dupeguru/qt/pe/image_viewer.py

1272 lines
47 KiB
Python

# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import (
QObject, Qt, QSize, QRectF, QPointF, QPoint, pyqtSlot, pyqtSignal, QEvent)
from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor, QIcon, QKeySequence
from PyQt5.QtWidgets import (
QGraphicsView, QGraphicsScene, QGraphicsPixmapItem,
QToolBar, QToolButton, QAction, QWidget, QScrollArea,
QApplication, QAbstractScrollArea, QStyle)
from hscommon.trans import trget
tr = trget("ui")
MAX_SCALE = 12.0
MIN_SCALE = 0.1
def createActions(actions, target):
# actions = [(name, shortcut, icon, desc, func)]
for name, shortcut, icon, desc, func in actions:
action = QAction(target)
if icon:
action.setIcon(QIcon.fromTheme(icon))
if shortcut:
action.setShortcut(shortcut)
action.setText(desc)
action.triggered.connect(func)
setattr(target, name, action)
class ViewerToolBar(QToolBar):
def __init__(self, parent, controller):
super().__init__(parent)
self.parent = parent
self.controller = controller
self.setupActions(controller)
self.createButtons()
self.buttonImgSwap.setEnabled(False)
self.buttonZoomIn.setEnabled(False)
self.buttonZoomOut.setEnabled(False)
self.buttonNormalSize.setEnabled(False)
self.buttonBestFit.setEnabled(False)
def setupActions(self, controller):
# actions = [(name, shortcut, icon, desc, func)]
ACTIONS = [
(
"actionZoomIn",
QKeySequence.ZoomIn,
"zoom-in",
tr("Increase zoom"),
controller.zoomIn,
),
(
"actionZoomOut",
QKeySequence.ZoomOut,
"zoom-out",
tr("Decrease zoom"),
controller.zoomOut,
),
(
"actionNormalSize",
tr("Ctrl+/"),
"zoom-original",
tr("Normal size"),
controller.zoomNormalSize,
),
(
"actionBestFit",
tr("Ctrl+*"),
"zoom-best-fit",
tr("Best fit"),
controller.zoomBestFit,
)
]
# TODO try with QWidgetAction() instead in order to have
# the popup menu work in the toolbar (if resized below minimum height)
createActions(ACTIONS, self)
def createButtons(self):
self.buttonImgSwap = QToolButton(self)
self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonImgSwap.setIcon(
QIcon.fromTheme('view-refresh',
self.style().standardIcon(QStyle.SP_BrowserReload)))
self.buttonImgSwap.setText('Swap images')
self.buttonImgSwap.setToolTip('Swap images')
self.buttonImgSwap.pressed.connect(self.controller.swapImages)
self.buttonImgSwap.released.connect(self.controller.swapImages)
self.buttonZoomIn = QToolButton(self)
self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonZoomIn.setDefaultAction(self.actionZoomIn)
# self.buttonZoomIn.setText('ZoomIn')
# self.buttonZoomIn.setIcon(QIcon.fromTheme('zoom-in'))
self.buttonZoomIn.setEnabled(False)
self.buttonZoomOut = QToolButton(self)
self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonZoomOut.setDefaultAction(self.actionZoomOut)
# self.buttonZoomOut.setText('ZoomOut')
# self.buttonZoomOut.setIcon(QIcon.fromTheme('zoom-out'))
self.buttonZoomOut.setEnabled(False)
self.buttonNormalSize = QToolButton(self)
self.buttonNormalSize.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonNormalSize.setDefaultAction(self.actionNormalSize)
# self.buttonNormalSize.setText('Normal Size')
# self.buttonNormalSize.setIcon(QIcon.fromTheme('zoom-original'))
self.buttonNormalSize.setEnabled(True)
self.buttonBestFit = QToolButton(self)
self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.buttonBestFit.setDefaultAction(self.actionBestFit)
# self.buttonBestFit.setText('BestFit')
# self.buttonBestFit.setIcon(QIcon.fromTheme('zoom-best-fit'))
self.buttonBestFit.setEnabled(False)
self.addWidget(self.buttonImgSwap)
self.addWidget(self.buttonZoomIn)
self.addWidget(self.buttonZoomOut)
self.addWidget(self.buttonNormalSize)
self.addWidget(self.buttonBestFit)
class BaseController(QObject):
"""Abstract Base class. Singleton.
Base proxy interface to keep image viewers synchronized.
Relays function calls, keep tracks of things."""
def __init__(self, parent):
super().__init__()
self.selectedViewer = None
self.referenceViewer = None
# cached pixmaps
self.selectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.scaledSelectedPixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.current_scale = 1.0
self.bestFit = True
self.parent = parent # To change buttons' states
self.cached_group = None
def setupViewers(self, selectedViewer, referenceViewer):
self.selectedViewer = selectedViewer
self.referenceViewer = referenceViewer
self.selectedViewer.controller = self
self.referenceViewer.controller = self
self._setupConnections()
def _setupConnections(self):
self.selectedViewer.connectMouseSignals()
self.referenceViewer.connectMouseSignals()
def updateView(self, ref, dupe, group):
# To keep current scale accross dupes from the same group
same_group = True
if group != self.cached_group:
same_group = False
self.resetState()
self.cached_group = group
self.selectedPixmap = QPixmap(str(dupe.path))
if ref is dupe: # currently selected file is the actual reference file
self.referencePixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.parent.verticalToolBar.buttonImgSwap.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
else:
self.referencePixmap = QPixmap(str(ref.path))
self.parent.verticalToolBar.buttonImgSwap.setEnabled(True)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
self.updateBothImages(same_group)
self.centerViews(same_group and self.referencePixmap.isNull())
def updateBothImages(self, same_group=False):
# WARNING this is called on every resize event,
ignore_update = self.referencePixmap.isNull()
if ignore_update:
self.selectedViewer.ignore_signal = True
# the SelectedImageViewer widget sometimes ends up being bigger
# than the ReferenceImageViewer by one pixel, which distorts the
# scaled down pixmap for the reference, hence we'll reuse its size here.
selected_size = self._updateImage(
self.selectedPixmap, self.scaledSelectedPixmap,
self.selectedViewer, None, same_group)
self._updateImage(
self.referencePixmap, self.scaledReferencePixmap,
self.referenceViewer, selected_size, same_group)
if ignore_update:
self.selectedViewer.ignore_signal = False
def _updateImage(self, pixmap, scaledpixmap, viewer, target_size=None, same_group=False):
# WARNING this is called on every resize event, might need to split
# into a separate function depending on the implementation used
if pixmap.isNull():
# This should disable the blank widget
viewer.setImage(pixmap)
return
target_size = viewer.size()
if not viewer.bestFit:
if same_group:
viewer.setImage(pixmap)
return target_size
# zoomed in state, expand
# only if not same_group, we need full update
scaledpixmap = pixmap.scaled(
target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
else:
# best fit, keep ratio always
scaledpixmap = pixmap.scaled(
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
viewer.setImage(scaledpixmap)
return target_size
def resetState(self):
"""Only called when the group of dupes has changed. We reset our
controller internal state and buttons, center view on viewers."""
self.selectedPixmap = QPixmap()
self.scaledSelectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.setBestFit(True)
self.current_scale = 1.0
self.selectedViewer.current_scale = 1.0
self.referenceViewer.current_scale = 1.0
self.selectedViewer.resetCenter()
self.referenceViewer.resetCenter()
self.selectedViewer.scaleAt(1.0)
self.referenceViewer.scaleAt(1.0)
self.centerViews()
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default
def resetViewersState(self):
"""No item from the model, disable and clear everything."""
# only called by the details dialog
self.selectedPixmap = QPixmap()
self.scaledSelectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.setBestFit(True)
self.current_scale = 1.0
self.selectedViewer.current_scale = 1.0
self.referenceViewer.current_scale = 1.0
self.selectedViewer.resetCenter()
self.referenceViewer.resetCenter()
self.selectedViewer.scaleAt(1.0)
self.referenceViewer.scaleAt(1.0)
self.centerViews()
self.parent.verticalToolBar.buttonImgSwap.setEnabled(False)
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(False)
self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default
self.selectedViewer.setImage(self.selectedPixmap) # null
self.selectedViewer.setEnabled(False)
self.referenceViewer.setImage(self.referencePixmap) # null
self.referenceViewer.setEnabled(False)
@pyqtSlot()
def zoomIn(self):
self.scaleImagesBy(1.25)
@pyqtSlot()
def zoomOut(self):
self.scaleImagesBy(0.8)
@pyqtSlot(float)
def scaleImagesBy(self, factor):
"""Compute new scale from factor and scale."""
self.current_scale *= factor
self.selectedViewer.scaleBy(factor)
self.referenceViewer.scaleBy(factor)
self.updateButtons()
@pyqtSlot(float)
def scaleImagesAt(self, scale):
"""Scale at a pre-computed scale."""
self.current_scale = scale
self.selectedViewer.scaleAt(scale)
self.referenceViewer.scaleAt(scale)
self.updateButtons()
def updateButtons(self):
self.parent.verticalToolBar.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0)
self.parent.verticalToolBar.buttonBestFit.setEnabled(self.bestFit is False)
@pyqtSlot()
def zoomBestFit(self):
"""Setup before scaling to bestfit"""
self.setBestFit(True)
self.current_scale = 1.0
self.selectedViewer.current_scale = 1.0
self.referenceViewer.current_scale = 1.0
self.selectedViewer.scaleAt(1.0)
self.referenceViewer.scaleAt(1.0)
self.selectedViewer.resetCenter()
self.referenceViewer.resetCenter()
target_size = self._updateImage(
self.selectedPixmap, self.scaledSelectedPixmap,
self.selectedViewer, None, True)
self._updateImage(
self.referencePixmap, self.scaledReferencePixmap,
self.referenceViewer, target_size, True)
self.centerViews()
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
self.parent.verticalToolBar.buttonBestFit.setEnabled(False)
def setBestFit(self, value):
self.bestFit = value
self.selectedViewer.bestFit = value
self.referenceViewer.bestFit = value
@pyqtSlot()
def zoomNormalSize(self):
self.setBestFit(False)
self.current_scale = 1.0
self.selectedViewer.setImage(self.selectedPixmap)
self.referenceViewer.setImage(self.referencePixmap)
self.centerViews()
self.selectedViewer.scaleToNormalSize()
self.referenceViewer.scaleToNormalSize()
self.parent.verticalToolBar.buttonZoomIn.setEnabled(True)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(True)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(False)
self.parent.verticalToolBar.buttonBestFit.setEnabled(True)
def centerViews(self, only_selected=False):
self.selectedViewer.centerViewAndUpdate()
if only_selected:
return
self.referenceViewer.centerViewAndUpdate()
@pyqtSlot()
def swapImages(self):
# swap the columns in the details table as well
self.parent.tableView.horizontalHeader().swapSections(1, 2)
class QWidgetController(BaseController):
"""Specialized version for QWidget-based viewers."""
def __init__(self, parent):
super().__init__(parent)
@pyqtSlot(QPointF)
def onDraggedMouse(self, delta):
if self.sender() is self.referenceViewer:
self.selectedViewer.onDraggedMouse(delta)
else:
self.referenceViewer.onDraggedMouse(delta)
@pyqtSlot()
def swapImages(self):
self.selectedViewer._pixmap.swap(self.referenceViewer._pixmap)
self.selectedViewer.centerViewAndUpdate()
self.referenceViewer.centerViewAndUpdate()
super().swapImages()
class ScrollAreaController(BaseController):
"""Specialized version fro QLabel-based viewers."""
def __init__(self, parent):
super().__init__(parent)
def _setupConnections(self):
super()._setupConnections()
self.selectedViewer.connectScrollBars()
self.referenceViewer.connectScrollBars()
def updateBothImages(self, same_group=False):
super().updateBothImages(same_group)
if not self.referenceViewer.isEnabled():
return
self.referenceViewer._horizontalScrollBar.setValue(
self.selectedViewer._horizontalScrollBar.value())
self.referenceViewer._verticalScrollBar.setValue(
self.selectedViewer._verticalScrollBar.value())
@pyqtSlot(QPoint)
def onDraggedMouse(self, delta):
self.selectedViewer.ignore_signal = True
self.referenceViewer.ignore_signal = True
self.selectedViewer.onDraggedMouse(delta)
self.referenceViewer.onDraggedMouse(delta)
self.selectedViewer.ignore_signal = False
self.referenceViewer.ignore_signal = False
@pyqtSlot()
def swapImages(self):
self.referenceViewer._pixmap.swap(self.selectedViewer._pixmap)
self.referenceViewer.setCachedPixmap()
self.selectedViewer.setCachedPixmap()
super().swapImages()
@pyqtSlot(float, QPointF)
def onMouseWheel(self, scale, delta):
self.scaleImagesAt(scale)
self.selectedViewer.adjustScrollBarsScaled(delta)
# Signal from scrollbars will automatically change the other:
# self.referenceViewer.adjustScrollBarsScaled(delta)
@pyqtSlot(int)
def onVScrollBarChanged(self, value):
if self.sender() is self.referenceViewer._verticalScrollBar:
if not self.selectedViewer.ignore_signal:
self.selectedViewer._verticalScrollBar.setValue(value)
else:
if not self.referenceViewer.ignore_signal:
self.referenceViewer._verticalScrollBar.setValue(value)
@pyqtSlot(int)
def onHScrollBarChanged(self, value):
if self.sender() is self.referenceViewer._horizontalScrollBar:
if not self.selectedViewer.ignore_signal:
self.selectedViewer._horizontalScrollBar.setValue(value)
else:
if not self.referenceViewer.ignore_signal:
self.referenceViewer._horizontalScrollBar.setValue(value)
@pyqtSlot(float)
def scaleImagesBy(self, factor):
super().scaleImagesBy(factor)
# The other is automatically updated via sigals
self.selectedViewer.adjustScrollBarsFactor(factor)
@pyqtSlot()
def zoomBestFit(self):
# Disable scrollbars to avoid GridLayout size rounding glitch
super().zoomBestFit()
self.selectedViewer.toggleScrollBars()
self.referenceViewer.toggleScrollBars()
class GraphicsViewController(BaseController):
"""Specialized version fro QGraphicsView-based viewers."""
def __init__(self, parent):
super().__init__(parent)
def _setupConnections(self):
super()._setupConnections()
self.selectedViewer.connectScrollBars()
self.referenceViewer.connectScrollBars()
# Special case for mouse wheel event conflicting with scrollbar adjustments
self.selectedViewer.other_viewer = self.referenceViewer
self.referenceViewer.other_viewer = self.selectedViewer
@pyqtSlot()
def syncCenters(self):
if self.sender() is self.referenceViewer:
self.selectedViewer.setCenter(self.referenceViewer._centerPoint)
else:
self.referenceViewer.setCenter(self.selectedViewer._centerPoint)
@pyqtSlot(float, QPointF)
def onMouseWheel(self, factor, newCenter):
self.current_scale *= factor
if self.sender() is self.referenceViewer:
self.selectedViewer.scaleBy(factor)
self.selectedViewer.setCenter(newCenter)
else:
self.referenceViewer.scaleBy(factor)
self.referenceViewer.setCenter(newCenter)
@pyqtSlot(int)
def onVScrollBarChanged(self, value):
if self.sender() is self.referenceViewer._verticalScrollBar:
if not self.selectedViewer.ignore_signal:
self.selectedViewer._verticalScrollBar.setValue(value)
else:
if not self.referenceViewer.ignore_signal:
self.referenceViewer._verticalScrollBar.setValue(value)
@pyqtSlot(int)
def onHScrollBarChanged(self, value):
if self.sender() is self.referenceViewer._horizontalScrollBar:
if not self.selectedViewer.ignore_signal:
self.selectedViewer._horizontalScrollBar.setValue(value)
else:
if not self.referenceViewer.ignore_signal:
self.referenceViewer._horizontalScrollBar.setValue(value)
@pyqtSlot()
def swapImages(self):
self.referenceViewer._pixmap.swap(self.selectedViewer._pixmap)
self.referenceViewer.setCachedPixmap()
self.selectedViewer.setCachedPixmap()
super().swapImages()
@pyqtSlot()
def zoomBestFit(self):
"""Setup before scaling to bestfit"""
self.setBestFit(True)
self.current_scale = 1.0
self.selectedViewer.fitScale()
self.referenceViewer.fitScale()
self.parent.verticalToolBar.buttonBestFit.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
def updateView(self, ref, dupe, group):
# Keep current scale accross dupes from the same group
same_group = True
if group != self.cached_group:
same_group = False
self.resetState()
self.cached_group = group
self.selectedPixmap = QPixmap(str(dupe.path))
if ref is dupe: # currently selected file is the actual reference file
self.referencePixmap = QPixmap()
self.parent.verticalToolBar.buttonImgSwap.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
else:
self.referencePixmap = QPixmap(str(ref.path))
self.parent.verticalToolBar.buttonImgSwap.setEnabled(True)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
self.selectedViewer.setImage(self.selectedPixmap)
self.referenceViewer.setImage(self.referencePixmap)
self.updateBothImages(same_group)
def updateBothImages(self, same_group=False):
"""This is called only during resize events and while bestFit."""
ignore_update = self.referencePixmap.isNull()
if ignore_update:
self.selectedViewer.ignore_signal = True
self._updateFitImage(
self.selectedPixmap, self.selectedViewer)
self._updateFitImage(
self.referencePixmap, self.referenceViewer)
if ignore_update:
self.selectedViewer.ignore_signal = False
def _updateFitImage(self, pixmap, viewer):
# If not same_group, we need full update"""
if pixmap.isNull():
return
if viewer.bestFit:
viewer.fitScale()
def resetState(self):
"""Only called when the group of dupes has changed. We reset our
controller internal state and buttons, center view on viewers."""
self.selectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.setBestFit(True)
self.current_scale = 1.0
self.selectedViewer.current_scale = 1.0
self.referenceViewer.current_scale = 1.0
self.selectedViewer.resetCenter()
self.referenceViewer.resetCenter()
self.selectedViewer.fitScale()
self.referenceViewer.fitScale()
# self.centerViews()
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
self.parent.verticalToolBar.buttonBestFit.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(True)
def resetViewersState(self):
"""No item from the model, disable and clear everything."""
# only called by the details dialog
self.selectedPixmap = QPixmap()
self.scaledSelectedPixmap = QPixmap()
self.referencePixmap = QPixmap()
self.scaledReferencePixmap = QPixmap()
self.setBestFit(True)
self.current_scale = 1.0
self.selectedViewer.current_scale = 1.0
self.referenceViewer.current_scale = 1.0
self.selectedViewer.resetCenter()
self.referenceViewer.resetCenter()
# self.centerViews()
self.parent.verticalToolBar.buttonZoomIn.setEnabled(False)
self.parent.verticalToolBar.buttonZoomOut.setEnabled(False)
self.parent.verticalToolBar.buttonBestFit.setEnabled(False)
self.parent.verticalToolBar.buttonImgSwap.setEnabled(False)
self.parent.verticalToolBar.buttonNormalSize.setEnabled(False)
self.selectedViewer.setImage(self.selectedPixmap) # null
self.selectedViewer.setEnabled(False)
self.referenceViewer.setImage(self.referencePixmap) # null
self.referenceViewer.setEnabled(False)
@pyqtSlot(float)
def scaleImagesBy(self, factor):
self.selectedViewer.updateCenterPoint()
self.referenceViewer.updateCenterPoint()
super().scaleImagesBy(factor)
self.selectedViewer.centerOn(self.selectedViewer._centerPoint)
# Scrollbars sync themselves here
class QWidgetImageViewer(QWidget):
"""Use a QPixmap, but no scrollbars and no keyboard key sequence for navigation."""
# FIXME: panning while zoomed-in is broken (due to delta not interpolated right?
mouseDragged = pyqtSignal(QPointF)
mouseWheeled = pyqtSignal(float)
def __init__(self, parent, name=""):
super().__init__(parent)
self._app = QApplication
self._pixmap = QPixmap()
self._rect = QRectF()
self._lastMouseClickPoint = QPointF()
self._mousePanningDelta = QPointF()
self.current_scale = 1.0
self._drag = False
self._dragConnection = None
self._wheelConnection = None
self._instance_name = name
self.bestFit = True
self.controller = None
self.setMouseTracking(False)
def __repr__(self):
return f'{self._instance_name}'
def connectMouseSignals(self):
if not self._dragConnection:
self._dragConnection = self.mouseDragged.connect(
self.controller.onDraggedMouse)
if not self._wheelConnection:
self._wheelConnection = self.mouseWheeled.connect(
self.controller.scaleImagesBy)
def disconnectMouseSignals(self):
if self._dragConnection:
self.mouseDragged.disconnect()
self._dragConnection = None
if self._wheelConnection:
self.mouseWheeled.disconnect()
self._wheelConnection = None
def paintEvent(self, event):
painter = QPainter(self)
painter.translate(self.rect().center())
painter.scale(self.current_scale, self.current_scale)
painter.translate(self._mousePanningDelta)
painter.drawPixmap(self._rect.topLeft(), self._pixmap)
def resetCenter(self):
""" Resets origin """
# Make sure we are not still panning around
self._mousePanningDelta = QPointF()
self.update()
def changeEvent(self, event):
if event.type() == QEvent.EnabledChange:
if self.isEnabled():
self.connectMouseSignals()
return
self.disconnectMouseSignals()
def mousePressEvent(self, event):
if self.bestFit or not self.isEnabled():
event.ignore()
return
if event.button() == Qt.LeftButton:
self._drag = True
else:
self._drag = False
event.ignore()
return
self._lastMouseClickPoint = event.pos()
self._app.setOverrideCursor(Qt.ClosedHandCursor)
self.setMouseTracking(True)
event.accept()
def mouseMoveEvent(self, event):
if self.bestFit or not self.isEnabled():
event.ignore()
return
self._mousePanningDelta += (
event.pos() - self._lastMouseClickPoint) * 1.0 / self.current_scale
self._lastMouseClickPoint = event.pos()
if self._drag:
self.mouseDragged.emit(self._mousePanningDelta)
self.update()
def mouseReleaseEvent(self, event):
if self.bestFit or not self.isEnabled():
event.ignore()
return
if event.button() == Qt.LeftButton:
self._drag = False
self._app.restoreOverrideCursor()
self.setMouseTracking(False)
def wheelEvent(self, event):
if self.bestFit or not self.isEnabled():
event.ignore()
return
if event.angleDelta().y() > 0:
if self.current_scale > MAX_SCALE:
return
self.mouseWheeled.emit(1.25) # zoom-in
else:
if self.current_scale < MIN_SCALE:
return
self.mouseWheeled.emit(0.8) # zoom-out
def setImage(self, pixmap):
if pixmap.isNull():
if not self._pixmap.isNull():
self._pixmap = pixmap
self.disconnectMouseSignals()
self.setEnabled(False)
self.update()
return
elif not self.isEnabled():
self.setEnabled(True)
self.connectMouseSignals()
self._pixmap = pixmap
def centerViewAndUpdate(self):
self._rect = self._pixmap.rect()
self._rect.translate(-self._rect.center())
self.update()
def shouldBeActive(self):
return True if not self.pixmap.isNull() else False
def scaleBy(self, factor):
self.current_scale *= factor
self.update()
def scaleAt(self, scale):
self.current_scale = scale
self.update()
def sizeHint(self):
return QSize(400, 400)
@pyqtSlot()
def scaleToNormalSize(self):
"""Called when the pixmap is set back to original size."""
self.current_scale = 1.0
self.update()
@pyqtSlot(QPointF)
def onDraggedMouse(self, delta):
self._mousePanningDelta = delta
self.update()
class ScalablePixmap(QWidget):
"""Container for a pixmap that scales up very fast, used in ScrollAreaImageViewer."""
def __init__(self, parent):
super().__init__(parent)
self._pixmap = QPixmap()
self.current_scale = 1.0
def paintEvent(self, event):
painter = QPainter(self)
painter.scale(self.current_scale, self.current_scale)
# painter.drawPixmap(self.rect().topLeft(), self._pixmap)
# should be the same as:
painter.drawPixmap(0, 0, self._pixmap)
def sizeHint(self):
return self._pixmap.size() * self.current_scale
def minimumSizeHint(self):
return self.sizeHint()
class ScrollAreaImageViewer(QScrollArea):
"""Implementation using a pixmap container in a simple scroll area."""
mouseDragged = pyqtSignal(QPoint)
mouseWheeled = pyqtSignal(float, QPointF)
def __init__(self, parent, name=""):
super().__init__(parent)
self._parent = parent
self._app = QApplication
self._pixmap = QPixmap()
self._scaledpixmap = None
self._rect = QRectF()
self._lastMouseClickPoint = QPointF()
self._mousePanningDelta = QPoint()
self.current_scale = 1.0
self._drag = False
self._dragConnection = None
self._wheelConnection = None
self._instance_name = name
self.wantScrollBars = True
self.bestFit = True
self.controller = None
self.label = ScalablePixmap(self)
# This is to avoid sending signals twice on scrollbar updates
self.ignore_signal = False
self.setBackgroundRole(QPalette.Dark)
self.setWidgetResizable(False)
self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents)
self.setAlignment(Qt.AlignCenter)
self._verticalScrollBar = self.verticalScrollBar()
self._horizontalScrollBar = self.horizontalScrollBar()
if self.wantScrollBars:
self.toggleScrollBars()
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setWidget(self.label)
self.setVisible(True)
def __repr__(self):
return f'{self._instance_name}'
def toggleScrollBars(self, forceOn=False):
if not self.wantScrollBars:
return
# Ensure that it's off on the first run
if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded:
if forceOn:
return
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
def connectMouseSignals(self):
if not self._dragConnection:
self._dragConnection = self.mouseDragged.connect(
self.controller.onDraggedMouse)
if not self._wheelConnection:
self._wheelConnection = self.mouseWheeled.connect(
self.controller.onMouseWheel)
def disconnectMouseSignals(self):
if self._dragConnection:
self.mouseDragged.disconnect()
self._dragConnection = None
if self._wheelConnection:
self.mouseWheeled.disconnect()
self._wheelConnection = None
def connectScrollBars(self):
"""Only call once controller is connected."""
# Cyclic connections are handled by Qt
self._verticalScrollBar.valueChanged.connect(
self.controller.onVScrollBarChanged, Qt.UniqueConnection)
self._horizontalScrollBar.valueChanged.connect(
self.controller.onHScrollBarChanged, Qt.UniqueConnection)
def mousePressEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.button() == Qt.LeftButton:
self._drag = True
else:
self._drag = False
event.ignore()
return
self._lastMouseClickPoint = event.pos()
self._app.setOverrideCursor(Qt.ClosedHandCursor)
self.setMouseTracking(True)
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.bestFit:
event.ignore()
return
if self._drag:
delta = (event.pos() - self._lastMouseClickPoint)
self._lastMouseClickPoint = event.pos()
self.mouseDragged.emit(delta)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.button() == Qt.LeftButton:
self._drag = False
self._app.restoreOverrideCursor()
self.setMouseTracking(False)
super().mouseReleaseEvent(event)
def wheelEvent(self, event):
if self.bestFit:
event.ignore()
return
oldScale = self.current_scale
if event.angleDelta().y() > 0: # zoom-in
if oldScale < MAX_SCALE:
self.current_scale *= 1.25
else:
if oldScale > MIN_SCALE: # zoom-out
self.current_scale *= 0.8
if oldScale == self.current_scale:
return
deltaToPos = (event.position() / oldScale) - (self.label.pos() / oldScale)
delta = (deltaToPos * self.current_scale) - (deltaToPos * oldScale)
self.mouseWheeled.emit(self.current_scale, delta)
def setImage(self, pixmap):
self._pixmap = pixmap
self.label._pixmap = pixmap
self.label.update()
self.label.adjustSize()
if pixmap.isNull():
self.setEnabled(False)
self.disconnectMouseSignals()
elif not self.isEnabled():
self.setEnabled(True)
self.connectMouseSignals()
def centerViewAndUpdate(self):
self._rect = self.label.rect()
self.label.rect().translate(-self._rect.center())
self.label.current_scale = self.current_scale
self.label.update()
# self.viewport().update()
def setCachedPixmap(self):
"""In case we have changed the cached pixmap, reset it."""
self.label._pixmap = self._pixmap
self.label.update()
def shouldBeActive(self):
return True if not self.pixmap.isNull() else False
def scaleBy(self, factor):
self.current_scale *= factor
# factor has to be either 1.25 or 0.8 here
self.label.resize(self.label.size().__imul__(factor))
self.label.current_scale = self.current_scale
self.label.update()
def scaleAt(self, scale):
self.current_scale = scale
self.label.resize(self._pixmap.size().__imul__(scale))
self.label.current_scale = scale
self.label.update()
# self.label.adjustSize()
def adjustScrollBarsFactor(self, factor):
"""After scaling, no mouse position, default to center."""
# scrollBar.setMaximum(scrollBar.maximum() - scrollBar.minimum() + scrollBar.pageStep())
self._horizontalScrollBar.setValue(
int(factor * self._horizontalScrollBar.value()
+ ((factor - 1) * self._horizontalScrollBar.pageStep() / 2)))
self._verticalScrollBar.setValue(
int(factor * self._verticalScrollBar.value()
+ ((factor - 1) * self._verticalScrollBar.pageStep() / 2)))
def adjustScrollBarsScaled(self, delta):
"""After scaling with the mouse, update relative to mouse position."""
self._horizontalScrollBar.setValue(
self._horizontalScrollBar.value() + delta.x())
self._verticalScrollBar.setValue(
self._verticalScrollBar.value() + delta.y())
def adjustScrollBarsAuto(self):
"""After panning, update accordingly."""
self.horizontalScrollBar().setValue(
self.horizontalScrollBar().value() - self._mousePanningDelta.x())
self.verticalScrollBar().setValue(
self.verticalScrollBar().value() - self._mousePanningDelta.y())
def adjustScrollBarCentered(self):
"""Just center in the middle."""
self._horizontalScrollBar.setValue(
int(self._horizontalScrollBar.maximum() / 2))
self._verticalScrollBar.setValue(
int(self._verticalScrollBar.maximum() / 2))
def resetCenter(self):
""" Resets origin """
self._mousePanningDelta = QPoint()
self.current_scale = 1.0
# self.scaleAt(1.0)
def setCenter(self, point):
self._lastMouseClickPoint = point
def sizeHint(self):
return self.viewport().rect().size()
@pyqtSlot()
def scaleToNormalSize(self):
"""Called when the pixmap is set back to original size."""
self.scaleAt(1.0)
self.ensureWidgetVisible(self.label) # needed for centering
self.toggleScrollBars(True)
@pyqtSlot(QPoint)
def onDraggedMouse(self, delta):
"""Update position from mouse delta sent by the other panel."""
self._mousePanningDelta = delta
# Signal from scrollbars had already synced the values here
self.adjustScrollBarsAuto()
# def viewportSizeHint(self):
# return self.viewport().rect().size()
# def changeEvent(self, event):
# if event.type() == QEvent.EnabledChange:
# print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}")
class GraphicsViewViewer(QGraphicsView):
"""Re-Implementation using a more full fledged class."""
mouseDragged = pyqtSignal()
mouseWheeled = pyqtSignal(float, QPointF)
def __init__(self, parent, name=""):
super().__init__(parent)
self._parent = parent
self._app = QApplication
self._pixmap = QPixmap()
self._scaledpixmap = None
self._rect = QRectF()
self._lastMouseClickPoint = QPointF()
self._mousePanningDelta = QPointF()
self._scaleFactor = 1.3
self.zoomInFactor = self._scaleFactor
self.zoomOutFactor = 1.0 / self._scaleFactor
self.current_scale = 1.0
self._drag = False
self._dragConnection = None
self._wheelConnection = None
self._instance_name = name
self.wantScrollBars = True
self.bestFit = True
self.controller = None
self._centerPoint = QPointF()
self.centerOn(self._centerPoint)
self.other_viewer = None
# specific to this class
self._scene = QGraphicsScene()
self._scene.setBackgroundBrush(Qt.black)
self._item = QGraphicsPixmapItem()
self.setScene(self._scene)
self._scene.addItem(self._item)
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
# self.matrix = QTransform()
self._horizontalScrollBar = self.horizontalScrollBar()
self._verticalScrollBar = self.verticalScrollBar()
self.ignore_signal = False
if self.wantScrollBars:
self.toggleScrollBars()
else:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setResizeAnchor(QGraphicsView.AnchorViewCenter)
self.setAlignment(Qt.AlignCenter)
self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)
self.setMouseTracking(True)
def connectMouseSignals(self):
if not self._dragConnection:
self._dragConnection = self.mouseDragged.connect(
self.controller.syncCenters)
if not self._wheelConnection:
self._wheelConnection = self.mouseWheeled.connect(
self.controller.onMouseWheel)
def disconnectMouseSignals(self):
if self._dragConnection:
self.mouseDragged.disconnect()
self._dragConnection = None
if self._wheelConnection:
self.mouseWheeled.disconnect()
self._wheelConnection = None
def connectScrollBars(self):
"""Only call once controller is connected."""
# Cyclic connections are handled by Qt
self._verticalScrollBar.valueChanged.connect(
self.controller.onVScrollBarChanged, Qt.UniqueConnection)
self._horizontalScrollBar.valueChanged.connect(
self.controller.onHScrollBarChanged, Qt.UniqueConnection)
def toggleScrollBars(self, forceOn=False):
if not self.wantScrollBars:
return
# Ensure that it's off on the first run
if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded:
if forceOn:
return
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
def mousePressEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.button() == Qt.LeftButton:
self._drag = True
else:
self._drag = False
event.ignore()
return
self._lastMouseClickPoint = event.pos()
self._app.setOverrideCursor(Qt.ClosedHandCursor)
self.setMouseTracking(True)
# We need to propagate to scrollbars, so we send back up
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self.bestFit:
event.ignore()
return
if event.button() == Qt.LeftButton:
self._drag = False
self._app.restoreOverrideCursor()
self.setMouseTracking(False)
self.updateCenterPoint()
super().mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
if self.bestFit:
event.ignore()
return
if self._drag:
self._lastMouseClickPoint = event.pos()
# We can simply rely on the scrollbar updating each other here
# self.mouseDragged.emit()
self.updateCenterPoint()
super().mouseMoveEvent(event)
def updateCenterPoint(self):
self._centerPoint = self.mapToScene(self.rect().center())
def wheelEvent(self, event):
if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE:
event.ignore()
return
pointBeforeScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos())))
# Get the original screen centerpoint
screenCenter = QPointF(self.mapToScene(self.rect().center()))
if event.angleDelta().y() > 0:
factor = self.zoomInFactor
else:
factor = self.zoomOutFactor
# Avoid scrollbars conflict:
self.other_viewer.ignore_signal = True
self.scaleBy(factor)
pointAfterScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos())))
# Get the offset of how the screen moved
offset = pointBeforeScale - pointAfterScale
# Adjust to the new center for correct zooming
newCenter = screenCenter + offset
self.setCenter(newCenter)
self.mouseWheeled.emit(factor, newCenter)
self.other_viewer.ignore_signal = False
def setImage(self, pixmap):
self._pixmap = pixmap
self._item.setPixmap(pixmap)
self.translate(1, 1)
def centerViewAndUpdate(self):
# Called from the base controller for Normal Size
pass
def setCenter(self, point):
self._centerPoint = point
self.centerOn(self._centerPoint)
def resetCenter(self):
""" Resets origin """
self._mousePanningDelta = QPointF()
self.current_scale = 1.0
def setNewCenter(self, position):
self._centerPoint = position
self.centerOn(self._centerPoint)
def setCachedPixmap(self):
"""In case we have changed the cached pixmap, reset it."""
self._item.setPixmap(self._pixmap)
self._item.update()
def scaleAt(self, scale):
# self.current_scale = scale
if scale == 1.0:
self.resetScale()
# self.setTransform( QTransform() )
self.scale(scale, scale)
def getScale(self):
return self.transform().m22()
def scaleBy(self, factor):
self.current_scale *= factor
super().scale(factor, factor)
def resetScale(self):
# self.setTransform( QTransform() )
self.resetTransform() # probably same as above
self.setCenter(self.scene().sceneRect().center())
def fitScale(self):
self.bestFit = True
super().fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio)
self.setNewCenter(self._scene.sceneRect().center())
@pyqtSlot()
def scaleToNormalSize(self):
"""Called when the pixmap is set back to original size."""
self.bestFit = False
self.scaleAt(1.0)
self.toggleScrollBars()
self.update()
def adjustScrollBarsScaled(self, delta):
"""After scaling with the mouse, update relative to mouse position."""
self._horizontalScrollBar.setValue(
self._horizontalScrollBar.value() + delta.x())
self._verticalScrollBar.setValue(
self._verticalScrollBar.value() + delta.y())
def sizeHint(self):
return self.viewport().rect().size()
def adjustScrollBarsFactor(self, factor):
"""After scaling, no mouse position, default to center."""
self._horizontalScrollBar.setValue(
int(factor * self._horizontalScrollBar.value()
+ ((factor - 1) * self._horizontalScrollBar.pageStep() / 2)))
self._verticalScrollBar.setValue(
int(factor * self._verticalScrollBar.value()
+ ((factor - 1) * self._verticalScrollBar.pageStep() / 2)))
def adjustScrollBarsAuto(self):
"""After panning, update accordingly."""
self.horizontalScrollBar().setValue(
self.horizontalScrollBar().value() - self._mousePanningDelta.x())
self.verticalScrollBar().setValue(
self.verticalScrollBar().value() - self._mousePanningDelta.y())