From 970bb5e19dc481928f5b7b785914f0c8b01a8d99 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sun, 21 Jun 2020 01:49:17 +0200 Subject: [PATCH] Add mostly working ScrollArea imge viewers * Work around flickering of scrollbars due to GridLayout resizing on odd pixels by disabling the scrollbars while BestFit is active * Also setting minimum column width to work around the issue above. * Avoid updating scrollbar values twice by using a simple boolean lock --- qt/pe/details_dialog.py | 65 ++-- qt/pe/image_viewer.py | 651 +++++++++++++++++++++------------------- 2 files changed, 375 insertions(+), 341 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index d2282efe..3c5c7102 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -6,9 +6,9 @@ from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal from PyQt5.QtGui import QPixmap, QIcon, QKeySequence -from PyQt5.QtWidgets import (QVBoxLayout, QAbstractItemView, QHBoxLayout, +from PyQt5.QtWidgets import (QLayout, QVBoxLayout, QAbstractItemView, QHBoxLayout, QLabel, QSizePolicy, QToolBar, QToolButton, QGridLayout, QStyle, QAction, - QWidget, QApplication ) + QWidget, QApplication, QSpacerItem ) from hscommon.trans import trget from hscommon import desktop @@ -29,26 +29,18 @@ class DetailsDialog(DetailsDialogBase): def setupActions(self): # (name, shortcut, icon, desc, func) ACTIONS = [ - ( - # FIXME probably not used right now - "actionSwap", - QKeySequence.Backspace, - "view-refresh", - tr("Swap images"), - self.swapImages, - ), ( "actionZoomIn", QKeySequence.ZoomIn, "zoom-in", - tr("Increase zoom factor"), + tr("Increase zoom"), self.zoomIn, ), ( "actionZoomOut", QKeySequence.ZoomOut, "zoom-out", - tr("Decrease zoom factor"), + tr("Decrease zoom"), self.zoomOut, ), ( @@ -78,14 +70,15 @@ class DetailsDialog(DetailsDialogBase): self.verticalLayout.setContentsMargins(0, 0, 0, 0) # self.horizontalLayout = QHBoxLayout() self.horizontalLayout = QGridLayout() + # Minimum width for the toolbar in the middle: self.horizontalLayout.setColumnMinimumWidth(1, 30) self.horizontalLayout.setColumnStretch(0,1) self.horizontalLayout.setColumnStretch(1,0) self.horizontalLayout.setColumnStretch(2,1) - self.horizontalLayout.setSpacing(4) + # self.horizontalLayout.setColumnStretch(3,0) + self.horizontalLayout.setSpacing(2) - self.selectedImageViewer = ScrollAreaImageViewer( - self, "selectedImage") + self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -99,8 +92,11 @@ class DetailsDialog(DetailsDialogBase): # # self.horizontalLayout.addWidget(self.selectedImage) self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1) + # self.horizontalLayout.addItem(QSpacerItem(5,0, QSizePolicy.Minimum), + # 1, 3, 1, 1, Qt.Alignment(Qt.AlignRight)) + self.verticalToolBar = QToolBar(self) - self.verticalToolBar.setOrientation(Qt.Orientation(2)) + self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) # self.subVLayout = QVBoxLayout(self) # self.subVLayout.addWidget(self.verticalToolBar) # self.horizontalLayout.addLayout(self.subVLayout) @@ -150,8 +146,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) - self.referenceImageViewer = ScrollAreaImageViewer( - self, "referenceImage") + self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") # self.referenceImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -159,11 +154,12 @@ class DetailsDialog(DetailsDialogBase): # sizePolicy.setHeightForWidth( # self.referenceImage.sizePolicy().hasHeightForWidth() # ) - # self.referenceImage.setSizePolicy(sizePolicy) - # self.referenceImage.setAlignment(Qt.AlignCenter) + # self.referenceImageViewer.setSizePolicy(sizePolicy) + # self.referenceImageViewer.setAlignment(Qt.AlignCenter) self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) - # self.horizontalLayout.addWidget(self.referenceImage) + # self.horizontalLayout.addWidget(self.referenceImageViewer) self.verticalLayout.addLayout(self.horizontalLayout) + self.tableView = DetailsTable(self) sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -176,6 +172,7 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) + self.tableView.hide() self.buttonImgSwap.setEnabled(False) self.buttonZoomIn.setEnabled(False) @@ -215,17 +212,21 @@ class DetailsDialog(DetailsDialogBase): return self.vController.update(ref, dupe) - def _updateImages(self): - if not self.vController.bestFit: - return - self.vController._updateImages() - # --- Override def resizeEvent(self, event): - if self.vController is None: + # HACK referenceViewer might be 1 pixel shorter in width + # due to dynamic resizing in the GridLayout's engine + # Couldn't find a way to ensure both viewers have the exact same size + # at all time without using resize(). + self.horizontalLayout.setColumnMinimumWidth( + 0, self.selectedImageViewer.size().width()) + self.horizontalLayout.setColumnMinimumWidth( + 2, self.selectedImageViewer.size().width()) + + if self.vController is None or not self.vController.bestFit: return - # update scaled down pixmaps - self._updateImages() + # Only update the scaled down pixmaps + self.vController._updateImages() def show(self): DetailsDialogBase.show(self) @@ -246,15 +247,15 @@ class DetailsDialog(DetailsDialogBase): @pyqtSlot() def zoomIn(self): - self.vController.zoom_in() + self.vController.scaleImagesBy(1.25) @pyqtSlot() def zoomOut(self): - self.vController.zoom_out() + self.vController.scaleImagesBy(0.8) @pyqtSlot() def zoomBestFit(self): - self.vController.scale_to_bestfit() + self.vController.ScaleToBestFit() @pyqtSlot() def zoomNormalSize(self): diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 90474b10..1deba4b3 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -11,32 +11,30 @@ from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, #TODO: add keyboard shortcuts class BaseController(QObject): - """Base proxy interface to keep image viewers synchronized. - Relays function calls. Singleton. """ + """Abstract Base class. Singleton. + Base proxy interface to keep image viewers synchronized. + Relays function calls, keep tracks of things.""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__() self.selectedViewer = selectedViewer self.referenceViewer = referenceViewer - + self.selectedViewer.controller = self + self.referenceViewer.controller = self + self._setupConnections() # cached pixmaps self.selectedPixmap = QPixmap() self.referencePixmap = QPixmap() self.scaledSelectedPixmap = QPixmap() self.scaledReferencePixmap = QPixmap() - self.current_scale = 1.0 - self._scaleFactor = 1.3 # how fast we zoom self.bestFit = True self.wantScrollBars = True - self.parent = parent #needed to change buttons' states - self.selectedViewer.controller = self - self.referenceViewer.controller = self - self._setupConnections() + self.parent = parent #To change buttons' states def _setupConnections(self): - self.selectedViewer.connect_signals() - self.referenceViewer.connect_signals() + self.selectedViewer.connectMouseSignals() + self.referenceViewer.connectMouseSignals() def update(self, ref, dupe): self.resetState() @@ -66,7 +64,7 @@ class BaseController(QObject): self.scaledSelectedPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.selectedViewer.setImage(self.scaledSelectedPixmap) - self.selectedViewer.center_and_update() + self.selectedViewer.centerViewAndUpdate() if not self.referencePixmap.isNull(): # the selectedImage viewer widget sometimes ends up being bigger @@ -80,31 +78,29 @@ class BaseController(QObject): self.scaledReferencePixmap = self.referencePixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.referenceViewer.setImage(self.scaledReferencePixmap) - self.referenceViewer.center_and_update() + self.referenceViewer.centerViewAndUpdate() - def zoom_in(self): - self.scaleImages(True) + @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) - def zoom_out(self): - self.scaleImages(False) + self.parent.buttonZoomIn.setEnabled(self.current_scale < 9.0) + self.parent.buttonZoomOut.setEnabled(self.current_scale > 0.5) + self.parent.buttonBestFit.setEnabled(self.bestFit is False) + self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) - @pyqtSlot(bool) # True = zoom-in - def scaleImages(self, zoom_type): + @pyqtSlot(float) + def scaleImagesAt(self, scale): + """Scale at a pre-computed scale.""" + self.current_scale = scale + self.selectedViewer.scaleAt(scale) + self.referenceViewer.scaleAt(scale) - if zoom_type: # zoom_in - self.current_scale *= self._scaleFactor - self.selectedViewer.zoom_in() - self.referenceViewer.zoom_in() - else: - self.current_scale /= self._scaleFactor - self.selectedViewer.zoom_out() - self.referenceViewer.zoom_out() - - # self.selectedViewer.scaleBy(self.scaleFactor) - # self.referenceViewer.scaleBy(self.scaleFactor) - - self.parent.buttonZoomIn.setEnabled(self.current_scale < 16.0) - self.parent.buttonZoomOut.setEnabled(self.current_scale > 1.0) + self.parent.buttonZoomIn.setEnabled(self.current_scale < 9.0) + self.parent.buttonZoomOut.setEnabled(self.current_scale > 0.5) self.parent.buttonBestFit.setEnabled(self.bestFit is False) self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) @@ -113,10 +109,8 @@ class BaseController(QObject): self.scaledSelectedPixmap = QPixmap() self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() - self.setBestFit(True) self.current_scale = 1.0 - self.selectedViewer.resetCenter() self.referenceViewer.resetCenter() @@ -136,13 +130,13 @@ class BaseController(QObject): self.parent.buttonNormalSize.setDisabled(True) @pyqtSlot() - def scale_to_bestfit(self): + def ScaleToBestFit(self): """Setup before scaling to bestfit""" self.setBestFit(True) self.current_scale = 1.0 - self.selectedViewer.scaleBy(1.0) - self.referenceViewer.scaleBy(1.0) + self.selectedViewer.scaleAt(1.0) + self.referenceViewer.scaleAt(1.0) self.selectedViewer.resetCenter() self.referenceViewer.resetCenter() @@ -166,27 +160,20 @@ class BaseController(QObject): self.selectedViewer.setImage(self.selectedPixmap) self.referenceViewer.setImage(self.referencePixmap) - self.selectedViewer.center_and_update() - self.referenceViewer.center_and_update() + self.selectedViewer.centerViewAndUpdate() + self.referenceViewer.centerViewAndUpdate() - self.selectedViewer.pixmapReset() - self.referenceViewer.pixmapReset() + self.selectedViewer.scaleToNormalSize() + self.referenceViewer.scaleToNormalSize() self.parent.buttonNormalSize.setEnabled(False) self.parent.buttonZoomIn.setEnabled(True) self.parent.buttonZoomOut.setEnabled(True) self.parent.buttonBestFit.setEnabled(True) - def syncCenters(self): # virtual - pass - - def swapPixmaps(self): #virtual - pass - - class QWidgetController(BaseController): - """Specialized version for QWidget-based viewers""" + """Specialized version for QWidget-based viewers.""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) @@ -200,23 +187,30 @@ class QWidgetController(BaseController): @pyqtSlot() def swapPixmaps(self): self.selectedViewer.getPixmap().swap(self.referenceViewer.getPixmap()) - self.selectedViewer.center_and_update() - self.referenceViewer.center_and_update() - - + self.selectedViewer.centerViewAndUpdate() + self.referenceViewer.centerViewAndUpdate() class ScrollAreaController(BaseController): - """Specialized version fro QLabel-based viewers""" + """Specialized version fro QLabel-based viewers.""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) - @pyqtSlot(QPointF) + def _setupConnections(self): + super()._setupConnections() + self.selectedViewer.connectScrollBars() + self.referenceViewer.connectScrollBars() + + @pyqtSlot(QPoint) def onDraggedMouse(self, delta): - if self.sender() is self.referenceViewer: - self.selectedViewer.onDraggedMouse(delta) - else: - self.referenceViewer.onDraggedMouse(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 swapPixmaps(self): @@ -226,42 +220,73 @@ class ScrollAreaController(BaseController): @pyqtSlot() def syncCenters(self): - self.selectedViewer.setCenter(self.referenceViewer.getCenter()) - self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + if self.sender() is self.referenceViewer: + self.selectedViewer.setCenter(self.referenceViewer.getCenter()) + else: + self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + @pyqtSlot(float, QPointF) + def onMouseWheel(self, scale, delta): + self.scaleImagesAt(scale) + self.selectedViewer.adjustScrollBarsScaled(delta) + # Signal will automatically change the other: + # self.referenceViewer.adjustScrollBarsScaled(delta) + @pyqtSlot(int) + def onVScrollBarChanged(self, value): + if self.sender() is self.referenceViewer: + self.selectedViewer._verticalScrollBar.setValue(value) + else: + self.referenceViewer._verticalScrollBar.setValue(value) + + @pyqtSlot(int) + def onHScrollBarChanged(self, value): + if self.sender() is self.referenceViewer: + if not 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 ScaleToBestFit(self): + # Disable scrollbars to avoid GridLayout size rounding "error" + super().ScaleToBestFit() + print("toggling scrollbars") + self.selectedViewer.toggleScrollBars() + self.referenceViewer.toggleScrollBars() class GraphicsViewController(BaseController): - """Specialized version fro QGraphicsView-based viewers""" + """Specialized version fro QGraphicsView-based viewers.""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) - @pyqtSlot(QPointF) - def onDraggedMouse(self, delta): - if self.sender() is self.referenceViewer: - self.selectedViewer.onDraggedMouse(delta) - else: - self.referenceViewer.onDraggedMouse(delta) - @pyqtSlot() def syncCenters(self): - self.selectedViewer.setCenter(self.referenceViewer.getCenter()) - self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + if self.sender() is self.referenceViewer: + self.selectedViewer.setCenter(self.referenceViewer.getCenter()) + else: + self.referenceViewer.setCenter(self.selectedViewer.getCenter()) class QWidgetImageViewer(QWidget): - """Uses a QPixmap as the center piece.""" + """Use a QPixmap, but no scrollbars.""" mouseDragged = pyqtSignal(QPointF) - mouseWheeled = pyqtSignal(bool) + mouseWheeled = pyqtSignal(float) def __init__(self, parent, name=""): super().__init__(parent) self._app = QApplication self._pixmap = QPixmap() self._rect = QRectF() - self._reference = QPointF() - self._delta = QPointF() - self._scaleFactor = 1.3 + self._lastMouseClickPoint = QPointF() + self._mousePanningDelta = QPointF() self._current_scale = 1.0 self._drag = False self._dragConnection = None @@ -277,27 +302,43 @@ class QWidgetImageViewer(QWidget): def getPixmap(self): return self._pixmap + 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._delta) + painter.translate(self._mousePanningDelta) painter.drawPixmap(self._rect.topLeft(), self._pixmap) - # print(f"{self} paintEvent delta={self._delta} current scale={self._current_scale}") def resetCenter(self): """ Resets origin """ - self._delta = QPointF() # FIXME does this even work? + # Make sure we are not still panning around + self._mousePanningDelta = QPointF() self.scaleBy(1.0) self.update() def changeEvent(self, event): if event.type() == QEvent.EnabledChange: - print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") + # print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") if self.isEnabled(): - self.connect_signals() + self.connectMouseSignals() return - self.disconnect_signals() + self.disconnectMouseSignals() def mousePressEvent(self, event): if self.bestFit: @@ -310,7 +351,7 @@ class QWidgetImageViewer(QWidget): event.ignore() return - self._reference = event.pos() + self._lastMouseClickPoint = event.pos() self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) event.accept() @@ -320,10 +361,11 @@ class QWidgetImageViewer(QWidget): event.ignore() return - self._delta += (event.pos() - self._reference) * 1.0 / self._current_scale - self._reference = event.pos() + self._mousePanningDelta += (event.pos() - self._lastMouseClickPoint) \ + * 1.0 / self._current_scale + self._lastMouseClickPoint = event.pos() if self._drag: - self.mouseDragged.emit(self._delta) + self.mouseDragged.emit(self._mousePanningDelta) self.update() def mouseReleaseEvent(self, event): @@ -342,23 +384,23 @@ class QWidgetImageViewer(QWidget): return if event.angleDelta().y() > 0: - self.mouseWheeled.emit(True) # zoom-in + self.mouseWheeled.emit(1.25) # zoom-in else: - self.mouseWheeled.emit(False) # zoom-out + self.mouseWheeled.emit(0.8) # zoom-out def setImage(self, pixmap): if pixmap.isNull(): if not self._pixmap.isNull(): self._pixmap = pixmap - self.disconnect_signals() + self.disconnectMouseSignals() self.update() return elif not self.isEnabled(): self.setEnabled(True) - self.connect_signals() + self.connectMouseSignals() self._pixmap = pixmap - def center_and_update(self): + def centerViewAndUpdate(self): self._rect = self._pixmap.rect() self._rect.translate(-self._rect.center()) self.update() @@ -366,92 +408,36 @@ class QWidgetImageViewer(QWidget): def shouldBeActive(self): return True if not self.pixmap.isNull() else False - def connect_signals(self): - if not self._dragConnection: - self._dragConnection = self.mouseDragged.connect( - self.controller.onDraggedMouse) - if not self._wheelConnection: - self._wheelConnection = self.mouseWheeled.connect( - self.controller.scaleImages) - - def disconnect_signals(self): - if self._dragConnection: - self.mouseDragged.disconnect() - self._dragConnection = None - if self._wheelConnection: - self.mouseWheeled.disconnect() - self._wheelConnection = None - - def zoom_in(self): - self._current_scale *= 1.25 - self.update() - - def zoom_out(self): - self._current_scale *= 0.8 - self.update() - def scaleBy(self, factor): - self._current_scale = 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 pixmapReset(self): + 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._delta = delta + self._mousePanningDelta = delta self.update() print(f"{self} received drag signal from {self.sender()}") - -class QLabelNoAA(QLabel): +class ScalablePixmap(QWidget): + """Container for a pixmap that scales up very fast""" def __init__(self, parent): super().__init__(parent) self._pixmap = QPixmap() self._current_scale = 1.0 - self._scaleFactor = 1.3 - self._delta = QPointF() - self._rect = QRectF() - - def paintEvent(self, event): - painter = QPainter(self) - painter.translate(self.rect().center()) - # painter.setRenderHint(QPainter.Antialiasing, False) - # scale the coordinate system: - painter.scale(self._current_scale, self._current_scale) - painter.translate(self._delta) - painter.drawPixmap(self.rect().topLeft(), self._pixmap) - print(f"LabelnoAA paintEvent scale {self._current_scale}") - - def setPixmap(self, pixmap): - self._pixmap = pixmap - # self.center_and_update() - super().setPixmap(pixmap) - - # def center_and_update(self): - # self._rect = self.rect() - # self._rect.translate(-self._rect.center()) - # self._update(self._current_scale) - - def sizeHint(self): - return self._pixmap.size() * self._current_scale - - -class ScalableWidget(QWidget): - def __init__(self, parent): - super().__init__() - self._pixmap = QPixmap() - self._current_scale = 1.0 - self._scaleFactor = 1.3 - self._delta = QPointF() - self._rect = QRectF() def paintEvent(self, event): painter = QPainter(self) @@ -459,30 +445,28 @@ class ScalableWidget(QWidget): # painter.setRenderHint(QPainter.Antialiasing, False) # scale the coordinate system: painter.scale(self._current_scale, self._current_scale) - painter.translate(self._delta) - painter.drawPixmap(self.rect().topLeft(), self._pixmap) - print(f"ScalableWidget paintEvent scale {self._current_scale}") + painter.drawPixmap(self.rect().topLeft(), self._pixmap) #same as (0,0, self.pixmap) + # print(f"ScalableWidget paintEvent scale {self._current_scale}") def setPixmap(self, pixmap): self._pixmap = pixmap - # self.center_and_update() - # super().setPixmap(pixmap) - - # def center_and_update(self): - # self._rect = self.rect() - # self._rect.translate(-self._rect.center()) - # self._update(self._current_scale) + # self.update() def sizeHint(self): - # if self._current_scale <= 1.0: - # return self._pixmap.size() return self._pixmap.size() * self._current_scale + # return self._pixmap.size() + + def minimumSizeHint(self): + return self.sizeHint() + + # def moveEvent(self, event): + # print(f"{self} moved by {event.pos()}") class ScrollAreaImageViewer(QScrollArea): """Version with Qlabel for testing""" - mouseDragged = pyqtSignal(QPointF) - mouseWheeled = pyqtSignal(bool) + mouseDragged = pyqtSignal(QPoint) + mouseWheeled = pyqtSignal(float, QPointF) def __init__(self, parent, name=""): super().__init__(parent) @@ -491,40 +475,33 @@ class ScrollAreaImageViewer(QScrollArea): self._pixmap = QPixmap() self._scaledpixmap = None self._rect = QRectF() - self._reference = QPointF() - self._delta = QPointF() - self._scaleFactor = 1.3 + self._lastMouseClickPoint = QPointF() + self._mousePanningDelta = QPoint() self._current_scale = 1.0 self._drag = False self._dragConnection = None self._wheelConnection = None + self._vBarConnection = None + self._hBarConnection = None self._instance_name = name self.wantScrollBars = True self.bestFit = True self.controller = None - - self.label = ScalableWidget(self) - - if isinstance(self.label, QLabelNoAA): - sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.label.sizePolicy().hasHeightForWidth()) - self.label.setBackgroundRole(QPalette.Base) - self.label.setSizePolicy(sizePolicy) - # self.label.setAlignment(Qt.AlignCenter) # useless? - self.label.setScaledContents(True) # Available in QLabel only, not used - # self.label.adjustSize() + 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.viewport().setAttribute(Qt.WA_StaticContents) - self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + self.setAlignment(Qt.AlignCenter) + + self._verticalScrollBar = self.verticalScrollBar() + self._horizontalScrollBar = self.horizontalScrollBar() if self.wantScrollBars: - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.toggleScrollBars() else: self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -532,34 +509,32 @@ class ScrollAreaImageViewer(QScrollArea): self.setWidget(self.label) self.setVisible(True) - if self.wantScrollBars: - self._verticalScrollBar = self.verticalScrollBar() - self._horizontalScrollBar = self.horizontalScrollBar() - - self._verticalScrollBar.rangeChanged.connect( - self.printvalue) - self._horizontalScrollBar.rangeChanged.connect( - self.printvalue) - - @pyqtSlot() - def printvalue(self): - print(f"verticalscrollbar.maximum: {self._verticalScrollBar.maximum()}") - def __repr__(self): return f'{self._instance_name}' def getPixmap(self): return self._pixmap - def connect_signals(self): + def toggleScrollBars(self): + if not self.wantScrollBars: + return + # Ensure that it's off on the first run + if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded: + 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) + self.controller.onDraggedMouse) if not self._wheelConnection: self._wheelConnection = self.mouseWheeled.connect( - self.controller.scaleImages) + self.controller.onMouseWheel) - def disconnect_signals(self): + def disconnectMouseSignals(self): if self._dragConnection: self.mouseDragged.disconnect() self._dragConnection = None @@ -567,9 +542,13 @@ class ScrollAreaImageViewer(QScrollArea): self.mouseWheeled.disconnect() self._wheelConnection = None - def changeEvent(self, event): - if event.type() == QEvent.EnabledChange: - print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") + 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: @@ -581,23 +560,21 @@ class ScrollAreaImageViewer(QScrollArea): self._drag = False event.ignore() return - - self._reference = event.pos() + self._lastMouseClickPoint = event.pos() self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) - event.accept() + super().mousePressEvent(event) def mouseMoveEvent(self, event): if self.bestFit: event.ignore() return - - self._delta += (event.pos() - self._reference) * 1.0/self._current_scale - self._reference = event.pos() if self._drag: - self.mouseDragged.emit(self._delta) - self.label._delta = self._delta - self.label.update() + delta = (event.pos() - self._lastMouseClickPoint) + self._lastMouseClickPoint = event.pos() + self.mouseDragged.emit(delta) + super().mouseMoveEvent(event) + # self.update() def mouseReleaseEvent(self, event): if self.bestFit: @@ -605,19 +582,22 @@ class ScrollAreaImageViewer(QScrollArea): 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: - self.mouseWheeled.emit(True) # zoom-in + self._current_scale *= 1.25 # zoom-in else: - self.mouseWheeled.emit(False) # zoom-out + self._current_scale *= 0.8 # zoom-out + 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, cache=True): if pixmap.isNull(): @@ -627,15 +607,14 @@ class ScrollAreaImageViewer(QScrollArea): return elif not self.isEnabled(): self.setEnabled(True) - self.connect_signals() - + self.connectMouseSignals() self._pixmap = pixmap self.label.setPixmap(pixmap) self.label.adjustSize() - def center_and_update(self): - self._rect = self.rect() - self._rect.translate(-self._rect.center()) + 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() @@ -648,68 +627,107 @@ class ScrollAreaImageViewer(QScrollArea): def shouldBeActive(self): return True if not self.pixmap.isNull() else False - def zoom_in(self): - self._current_scale *= 1.25 - self.scaleBy(self._current_scale) - - def zoom_out(self): - self._current_scale *= 0.8 - self.scaleBy(self._current_scale) - def scaleBy(self, factor): - print(f"{self} current_scale={self._current_scale}") + self._current_scale *= factor + # print(f"scaleBy(factor={factor}) current_scale={self._current_scale}") + # This kills my computer when scaling up! DO NOT USE! # self._pixmap = self._pixmap.scaled( # self._pixmap.size().__mul__(factor), # Qt.KeepAspectRatio, Qt.FastTransformation) - # self.label.setPixmap(self._pixmap) + # pointBeforeScale = QPoint(self.viewport().width() / 2, + # self.viewport().height() / 2) + pointBeforeScale = self.label.rect().center() + screenCenter = self.rect().center() - # This does nothing: - # newsize = self._pixmap.size().__imul__(factor) - # self.label.resize(newsize) - if self._current_scale < 1.0: - self.label.resize(self._pixmap.size()) + screenCenter.setX(screenCenter.x() + self.horizontalScrollBar().value()) + screenCenter.setY(screenCenter.y() + self.verticalScrollBar().value()) + # WARNING: factor has to be either 1.25 or 0.8 here! + self.label.resize(self.label.size().__imul__(factor)) + # self.label.updateGeometry() - - - # we might need a QRect here to update? - self.label._current_scale = factor + self.label._current_scale = self._current_scale self.label.update() + # Center view on zoom change(?) same as imageLabel->resize(imageLabel->pixmap()->size()) + # self.label.adjustSize() + # pointAfterScale = QPoint(self.viewport().width() / 2, + # self.viewport().height() / 2) + pointAfterScale = self.label.rect().center() + # print(f"label.newsize: {self.label.size()}\npointAfter: {pointAfterScale}") + offset = pointBeforeScale - pointAfterScale + newCenter = screenCenter - offset #FIXME need factor here somewhere + # print(f"offset: {offset} newCenter: {newCenter}\n-----------------") - self.label.adjustSize() # needed to center view on zoom change + # self.centerOn(newCenter) - if self.wantScrollBars: - self.adjustScrollBar(self.horizontalScrollBar(), factor) - self.adjustScrollBar(self.verticalScrollBar(), factor) + # self.adjustScrollBarCentered() + 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 adjustScrollBar(self, scrollBar, factor): - # scrollBar.setMaximum( - # scrollBar.maximum() - scrollBar.minimum() + scrollBar.pageStep()) - # scrollBar.setValue(int( - # factor * scrollBar.value() + - # ((factor - 1) * scrollBar.pageStep()/2))) - scrollBar.setValue(int(scrollBar.maximum() / 2)) + def centerOn(self, position): + # TODO here make widget move without the scrollbars if possible - # self.viewport().update() + self.ensureWidgetVisible(self.label) # moves back to center of label + # self.ensureVisible(position.x(), position.y()) + # self.scrollContentsBy(position.x(), position.y()) + + # hvalue = self.horizontalScrollBar().value() + # vvalue = self.verticalScrollBar().value() + # topLeft = self.viewport().rect().topLeft() + # self.label.move(topLeft.x() - hvalue, topLeft.y() - vvalue) + # self.label.updateGeometry() + + 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, scrollBar, factor): + """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._delta = QPointF() - self.label._delta = self._delta + self._mousePanningDelta = QPoint() + # self.label._mousePanningDelta = self._mousePanningDelta self._current_scale = 1.0 - self.scaleBy(1.0) + # self.scaleBy(1.0) # self.label.update() # already called in scaleBy def setCenter(self, point): - self._reference = point + self._lastMouseClickPoint = point def getCenter(self): - return self._reference + return self._lastMouseClickPoint def sizeHint(self): return self._pixmap.rect().size() @@ -718,29 +736,35 @@ class ScrollAreaImageViewer(QScrollArea): return self._pixmap.rect().size() @pyqtSlot() - def pixmapReset(self): + def scaleToNormalSize(self): """Called when the pixmap is set back to original size.""" - self._current_scale = 1.0 - self.scaleBy(1.0) - # self.ensureWidgetVisible(self.label) # might not need - self.label.update() + self.scaleAt(1.0) + self.ensureWidgetVisible(self.label) # needed for centering + # self.label.update() + self.toggleScrollBars() - @pyqtSlot(QPointF) + @pyqtSlot(QPoint) def onDraggedMouse(self, delta): - # This updates position from mouse delta from other panel - self._delta = delta - self.label._delta = delta - self.label.update() - print(f"{self} received mouse drag signal from {self.sender()}") + """Update position from mouse delta sent by the other panel.""" + self._mousePanningDelta = delta + # self.label.move(self.label.pos() + delta) + # self.label.update() + #FIXME signal from scrollbars has already synced the values here! + self.adjustScrollBarsAuto() + # print(f"{self} onDraggedMouse slot with delta {delta}") + + def changeEvent(self, event): + if event.type() == QEvent.EnabledChange: + print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem class GraphicsViewViewer(QGraphicsView): """Re-Implementation.""" - mouseDragged = pyqtSignal(QPointF) + mouseDragged = pyqtSignal() mouseWheeled = pyqtSignal(bool) def __init__(self, parent, name=""): @@ -750,8 +774,8 @@ class GraphicsViewViewer(QGraphicsView): self._pixmap = QPixmap() self._scaledpixmap = None self._rect = QRectF() - self._reference = QPointF() - self._delta = QPointF() + self._lastMouseClickPoint = QPointF() + self._mousePanningDelta = QPointF() self._scaleFactor = 1.3 self._current_scale = 1.0 self._drag = False @@ -761,7 +785,8 @@ class GraphicsViewViewer(QGraphicsView): self.wantScrollBars = True self.bestFit = True self.controller = None - self._centerPoint = QPointF() + self._centerPoint = QPointF(0.0, 0.0) + self.centerOn(self._centerPoint) # specific to this class self._scene = QGraphicsScene() @@ -779,17 +804,18 @@ class GraphicsViewViewer(QGraphicsView): self.setResizeAnchor(QGraphicsView.AnchorViewCenter) self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - # self.setViewportUpdateMode (QGraphicsView.FullViewportUpdate) + self.setViewportUpdateMode (QGraphicsView.FullViewportUpdate) + self.setMouseTracking(True) - def connect_signals(self): + def connectMouseSignals(self): if not self._dragConnection: self._dragConnection = self.mouseDragged.connect( - self.controller.onDraggedMouse) + self.controller.syncCenters) if not self._wheelConnection: self._wheelConnection = self.mouseWheeled.connect( self.controller.scaleImages) - def disconnect_signals(self): + def disconnectMouseSignals(self): if self._dragConnection: self.mouseDragged.disconnect() self._dragConnection = None @@ -808,22 +834,25 @@ class GraphicsViewViewer(QGraphicsView): event.ignore() return - self._reference = event.pos() + self._lastMouseClickPoint = event.pos() self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) - event.accept() + # We need to propagate to scrollbars, so we send back up + super().mousePressEvent(event) + # event.accept() def mouseMoveEvent(self, event): if self.bestFit: event.ignore() return - self._delta += (event.pos() - self._reference) * 1.0/self._current_scale - self._reference = event.pos() + self._centerPoint = self.mapToScene( self.rect().center() ) + super().mouseMoveEvent(event) + if self._drag: - self.mouseDragged.emit(self._delta) - self.label.update() - super().mouseMoveEvent(event) + self.mouseDragged.emit() + # self._item.update() + def mouseReleaseEvent(self, event): if self.bestFit: @@ -854,44 +883,48 @@ class GraphicsViewViewer(QGraphicsView): # self._item.setOffset(offset) # self.setSceneRect(offset.x()*4, offset.y()*4, -offset.x()*8, -offset.y()*8) self.translate(1, 1) - self._scene.setSceneRect(self._pixmap.rect()) + # self._scene.setSceneRect(self._pixmap.rect()) + + def centerViewAndUpdate(self): + pass def scaleBy(self, factor): # super().scale(factor, factor) self.zoom(factor) + def setCenter(self, point): + self._centerPoint = point + self.centerOn(self._centerPoint) + + def getCenter(self): + return self._centerPoint + def resetCenter(self): - # """ Resets origin """ - # self._delta = QPointF() - # self._scaleFactor = 1.0 - # self.scale(self._scaleFactor) - # self.update() - pass + """ Resets origin """ + self._mousePanningDelta = QPointF() + self._current_scale = 1.0 + self.scaleBy(1.0) + # self.update() + self.setCenter(self._scene.sceneRect().center()) def setNewCenter(self, position): self._centerPoint = position self.centerOn(self._centerPoint) @pyqtSlot() - def pixmapReset(self): + def scaleToNormalSize(self): """Called when the pixmap is set back to original size.""" - self.scaleBy(1.0) + self.scaleBy(1.0) # FIXME super().fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio ) self.setNewCenter(self._scene.sceneRect().center()) self.update() - @pyqtSlot(QPointF) - def onDraggedMouse(self, delta): - self._delta = delta - # self._item.move() - print(f"{self} received mouse drag signal from {self.sender()}") + # def sizeHint(self): + # return self._item.rect().size() - def sizeHint(self): - return self._item.rect().size() - - def viewportSizeHint(self): - return self._item.rect().size() + # def viewportSizeHint(self): + # return self._item.rect().size() def zoom_in(self): self.zoom(self._scaleFactor)