From 9b48e1851dd4a03ad7b12ec9b8b5dac1e1407ddc Mon Sep 17 00:00:00 2001 From: glubsy Date: Fri, 1 May 2020 18:22:25 +0200 Subject: [PATCH 01/61] add zoom and swap buttons to details dialog --- .gitignore | 4 +- qt/details_table.py | 2 +- qt/dg.qrc | 1 + qt/pe/details_dialog.py | 157 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 158 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 5e7c7043..774f1fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ cocoa/autogen *.pyd *.exe -*.spec \ No newline at end of file +*.spec + +.vscode diff --git a/qt/details_table.py b/qt/details_table.py index 1e353f17..6977a2b4 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -61,7 +61,7 @@ class DetailsTable(QTableView): hheader.setHighlightSections(False) hheader.setStretchLastSection(False) hheader.resizeSection(0, 100) - hheader.setSectionResizeMode(0, QHeaderView.Fixed) + hheader.setSectionResizeMode(0, QHeaderView.Interactive) hheader.setSectionResizeMode(1, QHeaderView.Stretch) hheader.setSectionResizeMode(2, QHeaderView.Stretch) vheader = self.verticalHeader() diff --git a/qt/dg.qrc b/qt/dg.qrc index 545a9806..eb5d735f 100644 --- a/qt/dg.qrc +++ b/qt/dg.qrc @@ -5,5 +5,6 @@ ../images/plus_8.png ../images/minus_8.png ../qtlib/images/search_clear_13.png + ../images/zoom-in.png diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 29c60899..a771bbc2 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -5,18 +5,24 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QSize -from PyQt5.QtGui import QPixmap +from PyQt5.QtGui import QPixmap, QIcon, QKeySequence from PyQt5.QtWidgets import ( QVBoxLayout, QAbstractItemView, QHBoxLayout, QLabel, QSizePolicy, + QToolBar, + QToolButton, + QGridLayout, + QStyle, + QAction ) from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable +from qtlib.util import createActions tr = trget("ui") @@ -26,15 +32,63 @@ class DetailsDialog(DetailsDialogBase): DetailsDialogBase.__init__(self, parent, app) self.selectedPixmap = None self.referencePixmap = None + self.ZoomFactor = 0 + + def _setupActions(self): + # (name, shortcut, icon, desc, func) + ACTIONS = [ + ( + "actionSwap", + QKeySequence.Backspace, + "swap", + tr("Swap images"), + self.swapImages, + ), + ( + "actionZoomIn", + QKeySequence.ZoomIn, + "zoom-in", + tr("Increase zoom factor"), + self.zoomIn, + ), + ( + "actionZoomOut", + QKeySequence.ZoomOut, + "zoom-out", + tr("Decrease zoom factor"), + self.zoomOut, + ), + ( + "actionZoomReset", + QKeySequence.Refresh, + "zoom-reset", + tr("Reset zoom factor"), + self.zoomReset, + ) + ] + createActions(ACTIONS, self) + + # special case as it resets when button is released + # self.actionSwap = QAction(self) + # # self.actionSwap.setIcon(QIcon(QPixmap())) + # self.actionSwap.setShortcut(QKeySequence.Backspace) + # self.actionSwap.setText(tr("Swap images")) + # self.actionSwap.pressed.connect(self.swapImages) def _setupUi(self): + self._setupActions() self.setWindowTitle(tr("Details")) self.resize(502, 295) self.setMinimumSize(QSize(250, 250)) self.verticalLayout = QVBoxLayout(self) self.verticalLayout.setSpacing(0) self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout = QHBoxLayout() + # self.horizontalLayout = QHBoxLayout() + self.horizontalLayout = QGridLayout() + 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.selectedImage = QLabel(self) sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) @@ -46,7 +100,51 @@ class DetailsDialog(DetailsDialogBase): self.selectedImage.setSizePolicy(sizePolicy) self.selectedImage.setScaledContents(False) self.selectedImage.setAlignment(Qt.AlignCenter) - self.horizontalLayout.addWidget(self.selectedImage) + # self.horizontalLayout.addWidget(self.selectedImage) + self.horizontalLayout.addWidget(self.selectedImage, 0, 0, 3, 1) + + self.verticalToolBar = QToolBar(self) + self.verticalToolBar.setOrientation(Qt.Orientation(2)) + # self.subVLayout = QVBoxLayout(self) + # self.subVLayout.addWidget(self.verticalToolBar) + # self.horizontalLayout.addLayout(self.subVLayout) + + self.buttonImgSwap = QToolButton(self.verticalToolBar) + self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonImgSwap.setIcon(QIcon.fromTheme('document-revert', \ + self.style().standardIcon(QStyle.SP_BrowserReload))) + self.buttonImgSwap.setText('Swap images') + self.buttonImgSwap.pressed.connect(self.swapImages) + self.buttonImgSwap.released.connect(self.swapImages) + + self.buttonZoomIn = QToolButton(self.verticalToolBar) + self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonZoomIn.setDefaultAction(self.actionZoomIn) + self.buttonZoomIn.setText('ZoomIn') + self.buttonZoomIn.setIcon(QIcon.fromTheme(('zoom-in'), QIcon(":images/zoom-in.png"))) + + self.buttonZoomOut = QToolButton(self.verticalToolBar) + 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.buttonResetZoom = QToolButton(self.verticalToolBar) + self.buttonResetZoom.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonResetZoom.setDefaultAction(self.actionZoomReset) + self.buttonResetZoom.setText('ResetZoom') + self.buttonResetZoom.setIcon(QIcon.fromTheme('zoom-original')) + self.buttonResetZoom.setEnabled(False) + + self.verticalToolBar.addWidget(self.buttonImgSwap) + self.verticalToolBar.addWidget(self.buttonZoomIn) + self.verticalToolBar.addWidget(self.buttonZoomOut) + self.verticalToolBar.addWidget(self.buttonResetZoom) + + self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) + # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) + self.referenceImage = QLabel(self) sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) sizePolicy.setHorizontalStretch(0) @@ -56,7 +154,8 @@ class DetailsDialog(DetailsDialogBase): ) self.referenceImage.setSizePolicy(sizePolicy) self.referenceImage.setAlignment(Qt.AlignCenter) - self.horizontalLayout.addWidget(self.referenceImage) + self.horizontalLayout.addWidget(self.referenceImage, 0, 2, 3, 1) + # self.horizontalLayout.addWidget(self.referenceImage) self.verticalLayout.addLayout(self.horizontalLayout) self.tableView = DetailsTable(self) sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -81,8 +180,10 @@ class DetailsDialog(DetailsDialogBase): self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: self.referencePixmap = None + self.buttonImgSwap.setEnabled(False) else: self.referencePixmap = QPixmap(str(ref.path)) + self.buttonImgSwap.setEnabled(True) self._updateImages() def _updateImages(self): @@ -116,3 +217,51 @@ class DetailsDialog(DetailsDialogBase): DetailsDialogBase.refresh(self) if self.isVisible(): self._update() + + def scaleImages(self, factor): + self.ZoomFactor = self.ZoomFactor + factor + print(f'Factor is now = {self.ZoomFactor}.') + + if 0 < self.ZoomFactor < 10: + self.buttonZoomIn.setEnabled(True) + self.buttonZoomOut.setEnabled(True) + self.buttonResetZoom.setEnabled(True) + elif self.ZoomFactor >= 10: + self.buttonZoomIn.setEnabled(False) + else: + self.buttonZoomIn.setEnabled(True) + self.buttonZoomOut.setEnabled(False) + self.buttonResetZoom.setEnabled(False) + + def swapImages(self): + # self.horizontalLayout.replaceWidget(self.selectedImage, self.referenceImage) + self._tempPixmap = self.referencePixmap + self.referencePixmap = self.selectedPixmap + self.selectedPixmap = self._tempPixmap + self._updateImages() + # swap the columns in the details table as well + self.tableView.horizontalHeader().swapSections(1, 2) + + def zoomIn(self): + if self.ZoomFactor >= 10: # clamping to x10 + return + print("ZoomIN") + self.scaleImages(1) + + + def zoomOut(self): + if self.ZoomFactor == 0: + return + print("ZoomOut") + self.scaleImages(-1) + + def zoomReset(self): + print("ZoomReset") + self.ZoomFactor = 0 + self.buttonResetZoom.setEnabled(False) + self.buttonZoomOut.setEnabled(False) + self.buttonZoomIn.setEnabled(True) + + def imagePan(self): + pass + From f42df12a29385fdd30d6940fc668f78a5afce129 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 2 May 2020 02:29:23 +0200 Subject: [PATCH 02/61] attempt at double click on Qlabel --- qt/pe/details_dialog.py | 86 ++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index a771bbc2..200b7c12 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,7 +4,7 @@ # 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 Qt, QSize +from PyQt5.QtCore import Qt, QSize, pyqtSignal, QModelIndex from PyQt5.QtGui import QPixmap, QIcon, QKeySequence from PyQt5.QtWidgets import ( QVBoxLayout, @@ -20,19 +20,29 @@ from PyQt5.QtWidgets import ( ) from hscommon.trans import trget +from hscommon import desktop from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from qtlib.util import createActions tr = trget("ui") +class ClickableLabel(QLabel): + def __init__(self, parent): + QLabel.__init__(self, parent) + self.path = "" + + def mouseDoubleClickEvent(self, event): + self.doubleClicked.emit() + + doubleClicked = pyqtSignal() class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): DetailsDialogBase.__init__(self, parent, app) self.selectedPixmap = None self.referencePixmap = None - self.ZoomFactor = 0 + self.scaleFactor = 0 def _setupActions(self): # (name, shortcut, icon, desc, func) @@ -90,7 +100,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(1,0) self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) - self.selectedImage = QLabel(self) + self.selectedImage = ClickableLabel(self) sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -101,6 +111,7 @@ class DetailsDialog(DetailsDialogBase): self.selectedImage.setScaledContents(False) self.selectedImage.setAlignment(Qt.AlignCenter) # self.horizontalLayout.addWidget(self.selectedImage) + self.selectedImage.doubleClicked.connect(self.mouseDoubleClickedEvent) self.horizontalLayout.addWidget(self.selectedImage, 0, 0, 3, 1) self.verticalToolBar = QToolBar(self) @@ -171,6 +182,7 @@ class DetailsDialog(DetailsDialogBase): self.verticalLayout.addWidget(self.tableView) def _update(self): + self._updateButtons() if not self.app.model.selected_dupes: return dupe = self.app.model.selected_dupes[0] @@ -184,25 +196,49 @@ class DetailsDialog(DetailsDialogBase): else: self.referencePixmap = QPixmap(str(ref.path)) self.buttonImgSwap.setEnabled(True) + self.scaleFactor = 0 + + self._updateButtons() self._updateImages() + def _updateButtons(self): + if 0 < self.scaleFactor < 10: + self.buttonZoomIn.setEnabled(True) + self.buttonZoomOut.setEnabled(True) + self.buttonResetZoom.setEnabled(True) + elif self.scaleFactor >= 10: + self.buttonZoomIn.setEnabled(False) + else: # scaleFactor == 0 + self.buttonZoomIn.setEnabled(True) + self.buttonZoomOut.setEnabled(False) + self.buttonResetZoom.setEnabled(False) + def _updateImages(self): if self.selectedPixmap is not None: target_size = self.selectedImage.size() - scaledPixmap = self.selectedPixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation - ) + if self.scaleFactor: + scaledPixmap = self.selectedPixmap.scaled( + target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) # widget expands here + else: + scaledPixmap = self.selectedPixmap.scaled( + target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.selectedImage.setPixmap(scaledPixmap) else: self.selectedImage.setPixmap(QPixmap()) + self.scaleFactor = 0 + if self.referencePixmap is not None: target_size = self.referenceImage.size() - scaledPixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation - ) + if self.scaleFactor: + scaledPixmap = self.referencePixmap.scaled( + target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) # widget expands here + else: + scaledPixmap = self.referencePixmap.scaled( + target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.referenceImage.setPixmap(scaledPixmap) else: self.referenceImage.setPixmap(QPixmap()) + self.scaleFactor = 0 # --- Override def resizeEvent(self, event): @@ -219,19 +255,9 @@ class DetailsDialog(DetailsDialogBase): self._update() def scaleImages(self, factor): - self.ZoomFactor = self.ZoomFactor + factor - print(f'Factor is now = {self.ZoomFactor}.') - - if 0 < self.ZoomFactor < 10: - self.buttonZoomIn.setEnabled(True) - self.buttonZoomOut.setEnabled(True) - self.buttonResetZoom.setEnabled(True) - elif self.ZoomFactor >= 10: - self.buttonZoomIn.setEnabled(False) - else: - self.buttonZoomIn.setEnabled(True) - self.buttonZoomOut.setEnabled(False) - self.buttonResetZoom.setEnabled(False) + self.scaleFactor += factor + print(f'Factor is now = {self.scaleFactor}.') + self._updateButtons() def swapImages(self): # self.horizontalLayout.replaceWidget(self.selectedImage, self.referenceImage) @@ -243,25 +269,31 @@ class DetailsDialog(DetailsDialogBase): self.tableView.horizontalHeader().swapSections(1, 2) def zoomIn(self): - if self.ZoomFactor >= 10: # clamping to x10 + if self.scaleFactor >= 10: # clamping to x10 return print("ZoomIN") self.scaleImages(1) - def zoomOut(self): - if self.ZoomFactor == 0: + if self.scaleFactor <= 0: return print("ZoomOut") self.scaleImages(-1) def zoomReset(self): print("ZoomReset") - self.ZoomFactor = 0 + self.scaleFactor = 0 self.buttonResetZoom.setEnabled(False) self.buttonZoomOut.setEnabled(False) self.buttonZoomIn.setEnabled(True) + self._updateImages() def imagePan(self): pass - + + def mouseDoubleClickedEvent(self, path): + desktop.open_path(path) + +# TODO: open default application when double click on label +# TODO: place handle above table view to drag and resize (splitter?) +# TODO: colorize or bolden values in table views when they differ? \ No newline at end of file From 468a736bfb39f9c495ce4a574f84e80ffac52507 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 2 May 2020 15:09:01 +0200 Subject: [PATCH 03/61] add normal size button --- qt/pe/details_dialog.py | 129 +++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 69 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 200b7c12..1118ffc8 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,7 +4,7 @@ # 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 Qt, QSize, pyqtSignal, QModelIndex +from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QPixmap, QIcon, QKeySequence from PyQt5.QtWidgets import ( QVBoxLayout, @@ -27,22 +27,13 @@ from qtlib.util import createActions tr = trget("ui") -class ClickableLabel(QLabel): - def __init__(self, parent): - QLabel.__init__(self, parent) - self.path = "" - - def mouseDoubleClickEvent(self, event): - self.doubleClicked.emit() - - doubleClicked = pyqtSignal() class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): DetailsDialogBase.__init__(self, parent, app) self.selectedPixmap = None self.referencePixmap = None - self.scaleFactor = 0 + self.scaleFactor = 1.0 def _setupActions(self): # (name, shortcut, icon, desc, func) @@ -69,22 +60,22 @@ class DetailsDialog(DetailsDialogBase): self.zoomOut, ), ( - "actionZoomReset", + "actionNormalSize", + QKeySequence.Refresh, + "zoom-normal", + tr("Normal size"), + self.zoomNormalSize, + ) + ( + "actionBestFit", QKeySequence.Refresh, "zoom-reset", - tr("Reset zoom factor"), - self.zoomReset, + tr("Best fit"), + self.zoomBestFit, ) ] createActions(ACTIONS, self) - # special case as it resets when button is released - # self.actionSwap = QAction(self) - # # self.actionSwap.setIcon(QIcon(QPixmap())) - # self.actionSwap.setShortcut(QKeySequence.Backspace) - # self.actionSwap.setText(tr("Swap images")) - # self.actionSwap.pressed.connect(self.swapImages) - def _setupUi(self): self._setupActions() self.setWindowTitle(tr("Details")) @@ -100,7 +91,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(1,0) self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) - self.selectedImage = ClickableLabel(self) + self.selectedImage = QLabel(self) sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -111,7 +102,6 @@ class DetailsDialog(DetailsDialogBase): self.selectedImage.setScaledContents(False) self.selectedImage.setAlignment(Qt.AlignCenter) # self.horizontalLayout.addWidget(self.selectedImage) - self.selectedImage.doubleClicked.connect(self.mouseDoubleClickedEvent) self.horizontalLayout.addWidget(self.selectedImage, 0, 0, 3, 1) self.verticalToolBar = QToolBar(self) @@ -141,17 +131,25 @@ class DetailsDialog(DetailsDialogBase): self.buttonZoomOut.setIcon(QIcon.fromTheme('zoom-out')) self.buttonZoomOut.setEnabled(False) - self.buttonResetZoom = QToolButton(self.verticalToolBar) - self.buttonResetZoom.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.buttonResetZoom.setDefaultAction(self.actionZoomReset) - self.buttonResetZoom.setText('ResetZoom') - self.buttonResetZoom.setIcon(QIcon.fromTheme('zoom-original')) - self.buttonResetZoom.setEnabled(False) + self.buttonNormalSize = QToolButton(self.verticalToolBar) + 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.verticalToolBar) + self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonBestFit.setDefaultAction(self.actionZoomReset) + self.buttonBestFit.setText('BestFit') + self.buttonBestFit.setIcon(QIcon.fromTheme('zoom-best-fit')) + self.buttonBestFit.setEnabled(False) self.verticalToolBar.addWidget(self.buttonImgSwap) self.verticalToolBar.addWidget(self.buttonZoomIn) self.verticalToolBar.addWidget(self.buttonZoomOut) - self.verticalToolBar.addWidget(self.buttonResetZoom) + self.verticalToolBar.addWidget(self.buttonNormalSize) + self.verticalToolBar.addWidget(self.buttonBestFit) self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) @@ -182,7 +180,6 @@ class DetailsDialog(DetailsDialogBase): self.verticalLayout.addWidget(self.tableView) def _update(self): - self._updateButtons() if not self.app.model.selected_dupes: return dupe = self.app.model.selected_dupes[0] @@ -196,40 +193,29 @@ class DetailsDialog(DetailsDialogBase): else: self.referencePixmap = QPixmap(str(ref.path)) self.buttonImgSwap.setEnabled(True) - self.scaleFactor = 0 - - self._updateButtons() - self._updateImages() + + self._resetButtons() - def _updateButtons(self): - if 0 < self.scaleFactor < 10: - self.buttonZoomIn.setEnabled(True) - self.buttonZoomOut.setEnabled(True) - self.buttonResetZoom.setEnabled(True) - elif self.scaleFactor >= 10: - self.buttonZoomIn.setEnabled(False) - else: # scaleFactor == 0 - self.buttonZoomIn.setEnabled(True) - self.buttonZoomOut.setEnabled(False) - self.buttonResetZoom.setEnabled(False) + self._updateImages() def _updateImages(self): if self.selectedPixmap is not None: target_size = self.selectedImage.size() - if self.scaleFactor: + if self.scaleFactor > 0: scaledPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) # widget expands here else: scaledPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.selectedImage.setPixmap(scaledPixmap) + # self.selectedImage.adjustSize() else: self.selectedImage.setPixmap(QPixmap()) - self.scaleFactor = 0 + self.scaleFactor = 1.0 if self.referencePixmap is not None: target_size = self.referenceImage.size() - if self.scaleFactor: + if self.scaleFactor > 0: scaledPixmap = self.referencePixmap.scaled( target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) # widget expands here else: @@ -238,7 +224,7 @@ class DetailsDialog(DetailsDialogBase): self.referenceImage.setPixmap(scaledPixmap) else: self.referenceImage.setPixmap(QPixmap()) - self.scaleFactor = 0 + self.scaleFactor = 1.0 # --- Override def resizeEvent(self, event): @@ -255,9 +241,19 @@ class DetailsDialog(DetailsDialogBase): self._update() def scaleImages(self, factor): - self.scaleFactor += factor + self.scaleFactor *= factor print(f'Factor is now = {self.scaleFactor}.') - self._updateButtons() + self.referenceImage.resize(self.scaleFactor * self.referencePixmap.size()) + self.selectedImage.resize(self.scaleFactor * self.selectedPixmap.size()) + + self.buttonZoomIn.setEnabled(self.scaleFactor < 3.0) + self.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) + self.buttonBestFit.setEnabled(self.scaleFactor != 1.0) + + def _resetButtons(self): + self.buttonZoomIn.setEnabled(True) + self.buttonZoomOut.setEnabled(False) + self.buttonBestFit.setEnabled(False) def swapImages(self): # self.horizontalLayout.replaceWidget(self.selectedImage, self.referenceImage) @@ -269,31 +265,26 @@ class DetailsDialog(DetailsDialogBase): self.tableView.horizontalHeader().swapSections(1, 2) def zoomIn(self): - if self.scaleFactor >= 10: # clamping to x10 - return - print("ZoomIN") - self.scaleImages(1) + self.scaleImages(1.25) def zoomOut(self): - if self.scaleFactor <= 0: - return - print("ZoomOut") - self.scaleImages(-1) + self.scaleImages(0.8) - def zoomReset(self): - print("ZoomReset") - self.scaleFactor = 0 - self.buttonResetZoom.setEnabled(False) + def zoomNormalSize(self): + self.scaleFactor = 1.0 + + def zoomBestFit(self): + self.scaleFactor = 1.0 + self.referenceImage.resize(self.scaleFactor * self.referencePixmap.size()) + self.selectedImage.resize(self.scaleFactor * self.selectedPixmap.size()) + self.buttonBestFit.setEnabled(False) self.buttonZoomOut.setEnabled(False) self.buttonZoomIn.setEnabled(True) self._updateImages() def imagePan(self): pass - - def mouseDoubleClickedEvent(self, path): - desktop.open_path(path) -# TODO: open default application when double click on label # TODO: place handle above table view to drag and resize (splitter?) -# TODO: colorize or bolden values in table views when they differ? \ No newline at end of file +# TODO: colorize or bolden values in table views when they differ? +# TODO: double click on details view PATH row opens path in default application \ No newline at end of file From ea6197626bfb90f5055e393535a8ab7b83ba0e08 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 2 May 2020 18:05:19 +0200 Subject: [PATCH 04/61] drag mouse with ImageViewer class --- qt/pe/details_dialog.py | 96 ++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 16 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 1118ffc8..44224eca 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,8 +4,8 @@ # 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 Qt, QSize -from PyQt5.QtGui import QPixmap, QIcon, QKeySequence +from PyQt5.QtCore import Qt, QSize, QRectF, QPointF +from PyQt5.QtGui import QPixmap, QIcon, QKeySequence, QPainter, QPalette from PyQt5.QtWidgets import ( QVBoxLayout, QAbstractItemView, @@ -16,7 +16,10 @@ from PyQt5.QtWidgets import ( QToolButton, QGridLayout, QStyle, - QAction + QAction, + QWidget, + QScrollArea, + QApplication ) from hscommon.trans import trget @@ -27,6 +30,65 @@ from qtlib.util import createActions tr = trget("ui") +class ImageViewer(QWidget): + def __init__(self, parent): + QWidget.__init__(self, parent) + self.app = QApplication + self.pixmap = QPixmap() + self.m_rect = QRectF() + self.reference = QPointF() + self.delta = QPointF() + self.scale = 1.0 + self.label = QLabel(parent) + self.area = QScrollArea() + + 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) + self.label.setScaledContents(True) + + self.area.setBackgroundRole(QPalette.Dark) + self.area.setWidget(self.label) + self.area.setVisible(False) + + def paintEvent(self, event): + painter = QPainter(self) + painter.translate(self.rect().center()) + painter.scale(self.scale, self.scale) + painter.translate(self.delta) + painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) + + def mousePressEvent(self, event): + self.reference = event.pos() + self.app.setOverrideCursor(Qt.ClosedHandCursor) + self.setMouseTracking(True) + + def mouseMoveEvent(self, event): + self.delta += (event.pos() - self.reference) * 1.0/self.scale + self.reference = event.pos() + self.update() + + def mouseReleaseEvent(self, event): + self.app.restoreOverrideCursor() + self.setMouseTracking(False) + + def setPixmap(self, pixmap): + self.pixmap = pixmap + self.m_rect = self.pixmap.rect() + self.m_rect.translate(-self.m_rect.center()) + self.update() + + def scale(self, factor): + self.scale *= factor + self.update() + + def sizeHint(self): + return QSize(400, 400) + class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): @@ -34,6 +96,7 @@ class DetailsDialog(DetailsDialogBase): self.selectedPixmap = None self.referencePixmap = None self.scaleFactor = 1.0 + self.app = app def _setupActions(self): # (name, shortcut, icon, desc, func) @@ -65,7 +128,7 @@ class DetailsDialog(DetailsDialogBase): "zoom-normal", tr("Normal size"), self.zoomNormalSize, - ) + ), ( "actionBestFit", QKeySequence.Refresh, @@ -91,17 +154,18 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(1,0) self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) - self.selectedImage = QLabel(self) - sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.selectedImage.sizePolicy().hasHeightForWidth() - ) - self.selectedImage.setSizePolicy(sizePolicy) - self.selectedImage.setScaledContents(False) - self.selectedImage.setAlignment(Qt.AlignCenter) - # self.horizontalLayout.addWidget(self.selectedImage) + self.selectedImage = ImageViewer(self) + # self.selectedImage = QLabel(self) + # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + # sizePolicy.setHorizontalStretch(0) + # sizePolicy.setVerticalStretch(0) + # sizePolicy.setHeightForWidth( + # self.selectedImage.sizePolicy().hasHeightForWidth() + # ) + # self.selectedImage.setSizePolicy(sizePolicy) + # self.selectedImage.setScaledContents(False) + # self.selectedImage.setAlignment(Qt.AlignCenter) + # # self.horizontalLayout.addWidget(self.selectedImage) self.horizontalLayout.addWidget(self.selectedImage, 0, 0, 3, 1) self.verticalToolBar = QToolBar(self) @@ -140,7 +204,7 @@ class DetailsDialog(DetailsDialogBase): self.buttonBestFit = QToolButton(self.verticalToolBar) self.buttonBestFit.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.buttonBestFit.setDefaultAction(self.actionZoomReset) + self.buttonBestFit.setDefaultAction(self.actionBestFit) self.buttonBestFit.setText('BestFit') self.buttonBestFit.setIcon(QIcon.fromTheme('zoom-best-fit')) self.buttonBestFit.setEnabled(False) From 02bd822ca09ec65b6255d4a8f5ef34e974fe0579 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 4 Jun 2020 02:33:54 +0200 Subject: [PATCH 05/61] working zoom functions, mouse wheel event --- qt/dg.qrc | 1 - qt/pe/details_dialog.py | 262 ++++++++++++++++++++++++++++------------ 2 files changed, 185 insertions(+), 78 deletions(-) diff --git a/qt/dg.qrc b/qt/dg.qrc index eb5d735f..545a9806 100644 --- a/qt/dg.qrc +++ b/qt/dg.qrc @@ -5,6 +5,5 @@ ../images/plus_8.png ../images/minus_8.png ../qtlib/images/search_clear_13.png - ../images/zoom-in.png diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 44224eca..02e21d9b 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,7 +4,7 @@ # 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 Qt, QSize, QRectF, QPointF +from PyQt5.QtCore import Qt, QSize, QRectF, QPointF, pyqtSlot from PyQt5.QtGui import QPixmap, QIcon, QKeySequence, QPainter, QPalette from PyQt5.QtWidgets import ( QVBoxLayout, @@ -19,7 +19,8 @@ from PyQt5.QtWidgets import ( QAction, QWidget, QScrollArea, - QApplication + QApplication, + QAbstractScrollArea ) from hscommon.trans import trget @@ -31,17 +32,24 @@ from qtlib.util import createActions tr = trget("ui") class ImageViewer(QWidget): + """ Displays image and allow manipulations """ def __init__(self, parent): - QWidget.__init__(self, parent) + super().__init__(parent) + self.parent = parent self.app = QApplication self.pixmap = QPixmap() self.m_rect = QRectF() self.reference = QPointF() self.delta = QPointF() - self.scale = 1.0 - self.label = QLabel(parent) - self.area = QScrollArea() + self.scalefactor = 1.0 + self.area = QScrollArea(parent) + self.area.setBackgroundRole(QPalette.Dark) + self.area.setWidgetResizable(True) + self.area.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + # self.area.viewport().setAttribute(Qt.WA_StaticContents) + + self.label = QLabel() sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -50,58 +58,91 @@ class ImageViewer(QWidget): self.label.setSizePolicy(sizePolicy) self.label.setAlignment(Qt.AlignCenter) self.label.setScaledContents(True) - - self.area.setBackgroundRole(QPalette.Dark) + self.area.setWidget(self.label) self.area.setVisible(False) def paintEvent(self, event): painter = QPainter(self) painter.translate(self.rect().center()) - painter.scale(self.scale, self.scale) + painter.scale(self.scalefactor, self.scalefactor) painter.translate(self.delta) painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) - + def mousePressEvent(self, event): + if self.parent.bestFit: + event.ignore() # probably not needed + return + self.reference = event.pos() self.app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) def mouseMoveEvent(self, event): - self.delta += (event.pos() - self.reference) * 1.0/self.scale + if self.parent.bestFit: + event.ignore() # probably not needed + return + + self.delta += (event.pos() - self.reference) * 1.0/self.scalefactor self.reference = event.pos() self.update() def mouseReleaseEvent(self, event): + if self.parent.bestFit: + event.ignore() # probably not needed + return + self.app.restoreOverrideCursor() self.setMouseTracking(False) + def wheelEvent(self, event): + if self.parent.bestFit: + event.ignore() # probably not needed + return + + if event.angleDelta().y() > 0: + self.parent.zoomIn() + else: + self.parent.zoomOut() + def setPixmap(self, pixmap): self.pixmap = pixmap + if pixmap is None: + return self.m_rect = self.pixmap.rect() self.m_rect.translate(-self.m_rect.center()) self.update() - + def scale(self, factor): - self.scale *= factor + self.scalefactor = factor + print(f"ImaveViewer.scalefactor={self.scalefactor}") + # self.label.resize(self.scalefactor * self.label.size()) self.update() - + def sizeHint(self): return QSize(400, 400) + @pyqtSlot() + def pixmapReset(self): + self.scalefactor = 1.0 + self.update() + class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): - DetailsDialogBase.__init__(self, parent, app) + super().__init__(parent, app) self.selectedPixmap = None self.referencePixmap = None + self.scaledSelectedPixmap = None + self.scaledReferencePixmap = None self.scaleFactor = 1.0 - self.app = app + self.bestFit = True - def _setupActions(self): + def setupActions(self): # (name, shortcut, icon, desc, func) ACTIONS = [ ( + # FIXME probably not used right now "actionSwap", QKeySequence.Backspace, "swap", @@ -131,7 +172,7 @@ class DetailsDialog(DetailsDialogBase): ), ( "actionBestFit", - QKeySequence.Refresh, + tr("Ctrl+p"), "zoom-reset", tr("Best fit"), self.zoomBestFit, @@ -140,7 +181,7 @@ class DetailsDialog(DetailsDialogBase): createActions(ACTIONS, self) def _setupUi(self): - self._setupActions() + self.setupActions() self.setWindowTitle(tr("Details")) self.resize(502, 295) self.setMinimumSize(QSize(250, 250)) @@ -154,6 +195,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(1,0) self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) + self.selectedImage = ImageViewer(self) # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) @@ -179,8 +221,9 @@ class DetailsDialog(DetailsDialogBase): self.buttonImgSwap.setIcon(QIcon.fromTheme('document-revert', \ self.style().standardIcon(QStyle.SP_BrowserReload))) self.buttonImgSwap.setText('Swap images') + self.buttonImgSwap.setToolTip('Swap images') self.buttonImgSwap.pressed.connect(self.swapImages) - self.buttonImgSwap.released.connect(self.swapImages) + self.buttonImgSwap.released.connect(self.deswapImages) self.buttonZoomIn = QToolButton(self.verticalToolBar) self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) @@ -218,15 +261,16 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) - self.referenceImage = QLabel(self) - sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.referenceImage.sizePolicy().hasHeightForWidth() - ) - self.referenceImage.setSizePolicy(sizePolicy) - self.referenceImage.setAlignment(Qt.AlignCenter) + self.referenceImage = ImageViewer(self) + # self.referenceImage = QLabel(self) + # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + # sizePolicy.setHorizontalStretch(0) + # sizePolicy.setVerticalStretch(0) + # sizePolicy.setHeightForWidth( + # self.referenceImage.sizePolicy().hasHeightForWidth() + # ) + # self.referenceImage.setSizePolicy(sizePolicy) + # self.referenceImage.setAlignment(Qt.AlignCenter) self.horizontalLayout.addWidget(self.referenceImage, 0, 2, 3, 1) # self.horizontalLayout.addWidget(self.referenceImage) self.verticalLayout.addLayout(self.horizontalLayout) @@ -250,48 +294,53 @@ class DetailsDialog(DetailsDialogBase): group = self.app.model.results.get_group_of_duplicate(dupe) ref = group.ref + self.resetState() self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: self.referencePixmap = None + self.scaledReferencePixmap = None self.buttonImgSwap.setEnabled(False) else: self.referencePixmap = QPixmap(str(ref.path)) self.buttonImgSwap.setEnabled(True) - - self._resetButtons() self._updateImages() def _updateImages(self): + target_size = None if self.selectedPixmap is not None: target_size = self.selectedImage.size() - if self.scaleFactor > 0: - scaledPixmap = self.selectedPixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) # widget expands here - else: - scaledPixmap = self.selectedPixmap.scaled( + if not self.bestFit: + # zoomed in state, expand + self.scaledSelectedPixmap = self.selectedPixmap.scaled( + target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + else: # best fit, keep ratio always + self.scaledSelectedPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.selectedImage.setPixmap(scaledPixmap) + self.selectedImage.setPixmap(self.scaledSelectedPixmap) # self.selectedImage.adjustSize() else: self.selectedImage.setPixmap(QPixmap()) - self.scaleFactor = 1.0 if self.referencePixmap is not None: - target_size = self.referenceImage.size() - if self.scaleFactor > 0: - scaledPixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) # widget expands here + # the selectedImage viewer widget sometimes ends up being bigger + # than the referenceImage viewer, which distorts by one pixel the + # scaled down pixmap for the reference, hence we'll reuse its size here. + # target_size = self.selectedImage.size() + if not self.bestFit: + self.scaledReferencePixmap = self.referencePixmap.scaled( + target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) else: - scaledPixmap = self.referencePixmap.scaled( + self.scaledReferencePixmap = self.referencePixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.referenceImage.setPixmap(scaledPixmap) + self.referenceImage.setPixmap(self.scaledReferencePixmap) else: self.referenceImage.setPixmap(QPixmap()) - self.scaleFactor = 1.0 # --- Override def resizeEvent(self, event): + if not self.bestFit: + return self._updateImages() def show(self): @@ -304,51 +353,110 @@ class DetailsDialog(DetailsDialogBase): if self.isVisible(): self._update() - def scaleImages(self, factor): - self.scaleFactor *= factor - print(f'Factor is now = {self.scaleFactor}.') - self.referenceImage.resize(self.scaleFactor * self.referencePixmap.size()) - self.selectedImage.resize(self.scaleFactor * self.selectedPixmap.size()) - - self.buttonZoomIn.setEnabled(self.scaleFactor < 3.0) - self.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) - self.buttonBestFit.setEnabled(self.scaleFactor != 1.0) - - def _resetButtons(self): - self.buttonZoomIn.setEnabled(True) + def resetState(self): + self.scaledReferencePixmap = None + self.scaledSelectedPixmapPixmap = None + self.buttonZoomIn.setEnabled(False) self.buttonZoomOut.setEnabled(False) - self.buttonBestFit.setEnabled(False) + self.buttonBestFit.setEnabled(False) # active mode by default + self.buttonNormalSize.setEnabled(True) + self.bestFit = True + self.scaleFactor = 1.0 + def scaleImages(self, factor): + self.scaleFactor *= factor + print(f'QDialog scaleFactor = {self.scaleFactor} (+factor {factor})') + + # returns QSize, not good anymore + # self.referenceImage.scale(self.scaleFactor * self.referencePixmap.size()) + # self.selectedImage.scale(self.scaleFactor * self.selectedPixmap.size()) + + self.referenceImage.scale(self.scaleFactor) + self.selectedImage.scale(self.scaleFactor) + + self.buttonZoomIn.setEnabled(self.scaleFactor < 6.0) + self.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) + self.buttonBestFit.setEnabled(self.bestFit is False) + self.buttonNormalSize.setEnabled(self.scaleFactor != 1.0) + + @pyqtSlot() def swapImages(self): + """ Swap pixmaps between ImageViewers """ # self.horizontalLayout.replaceWidget(self.selectedImage, self.referenceImage) - self._tempPixmap = self.referencePixmap - self.referencePixmap = self.selectedPixmap - self.selectedPixmap = self._tempPixmap - self._updateImages() + + # self._tempPixmap = self.referencePixmap + # referencePixmap = self.selectedPixmap + # self.selectedPixmap = self._tempPixmap + # self._updateImages() + + if self.bestFit: + self.selectedImage.setPixmap(self.scaledReferencePixmap) + self.referenceImage.setPixmap(self.scaledSelectedPixmap) + else: + self.selectedImage.setPixmap(self.referencePixmap) + self.referenceImage.setPixmap(self.selectedPixmap) + # swap the columns in the details table as well self.tableView.horizontalHeader().swapSections(1, 2) + @pyqtSlot() + def deswapImages(self): + """ Restore swapped pixmaps """ + if self.bestFit: + self.selectedImage.setPixmap(self.scaledSelectedPixmap) + self.referenceImage.setPixmap(self.scaledReferencePixmap) + else: + self.selectedImage.setPixmap(self.selectedPixmap) + self.referenceImage.setPixmap(self.referencePixmap) + + self.tableView.horizontalHeader().swapSections(1, 2) + + @pyqtSlot() def zoomIn(self): self.scaleImages(1.25) + @pyqtSlot() def zoomOut(self): self.scaleImages(0.8) - def zoomNormalSize(self): - self.scaleFactor = 1.0 - - def zoomBestFit(self): - self.scaleFactor = 1.0 - self.referenceImage.resize(self.scaleFactor * self.referencePixmap.size()) - self.selectedImage.resize(self.scaleFactor * self.selectedPixmap.size()) - self.buttonBestFit.setEnabled(False) - self.buttonZoomOut.setEnabled(False) - self.buttonZoomIn.setEnabled(True) + @pyqtSlot() + def scale_to_bestfit(self): + self.referenceImage.scale(self.scaleFactor) + self.selectedImage.scale(self.scaleFactor) self._updateImages() - def imagePan(self): - pass + @pyqtSlot() + def zoomBestFit(self): + self.bestFit = True + self.scaleFactor = 1.0 + self.buttonBestFit.setEnabled(False) + self.buttonZoomOut.setEnabled(False) + self.buttonZoomIn.setEnabled(False) + self.buttonNormalSize.setEnabled(True) + self.scale_to_bestfit() + + @pyqtSlot() + def zoomNormalSize(self): + self.bestFit = False + self.scaleFactor = 1.0 + + self.selectedImage.setPixmap(self.selectedPixmap) + + if self.referencePixmap is None: + self.referenceImage.setPixmap(QPixmap()) + else: + self.referenceImage.setPixmap(self.referencePixmap) + + self.selectedImage.pixmapReset() + self.referenceImage.pixmapReset() + # self.referenceImage.label.resize(self.scaleFactor * self.referencePixmap.size()) + # self.selectedImage.label.resize(self.scaleFactor * self.selectedPixmap.size()) + # self.referenceImage.label.adjustSize() + # self.selectedImage.label.adjustSize() + + self.buttonNormalSize.setEnabled(False) + self.buttonZoomIn.setEnabled(True) + self.buttonZoomOut.setEnabled(True) + self.buttonBestFit.setEnabled(True) + # self._updateImages() -# TODO: place handle above table view to drag and resize (splitter?) -# TODO: colorize or bolden values in table views when they differ? -# TODO: double click on details view PATH row opens path in default application \ No newline at end of file From c6162914ed4cc473f3e4ef0ff7e633a095e55fbf Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 4 Jun 2020 17:38:28 +0200 Subject: [PATCH 06/61] working synchronized panning --- qt/pe/details_dialog.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 02e21d9b..a1caf4b5 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,7 +4,7 @@ # 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 Qt, QSize, QRectF, QPointF, pyqtSlot +from PyQt5.QtCore import Qt, QSize, QRectF, QPointF, pyqtSlot, pyqtSignal, QEvent from PyQt5.QtGui import QPixmap, QIcon, QKeySequence, QPainter, QPalette from PyQt5.QtWidgets import ( QVBoxLayout, @@ -33,6 +33,8 @@ tr = trget("ui") class ImageViewer(QWidget): """ Displays image and allow manipulations """ + mouseMoved = pyqtSignal(QPointF) + def __init__(self, parent): super().__init__(parent) self.parent = parent @@ -62,16 +64,27 @@ class ImageViewer(QWidget): self.area.setWidget(self.label) self.area.setVisible(False) + @pyqtSlot(QPointF) + def slot_paint_event(self, delta): + self.delta = delta + self.update() + def paintEvent(self, event): painter = QPainter(self) painter.translate(self.rect().center()) painter.scale(self.scalefactor, self.scalefactor) painter.translate(self.delta) painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) + self.mouseMoved.emit(self.delta) + + def setCenter(self): + """ Resets origin """ + self.delta = QPointF() + self.update() def mousePressEvent(self, event): if self.parent.bestFit: - event.ignore() # probably not needed + event.ignore() return self.reference = event.pos() @@ -79,8 +92,8 @@ class ImageViewer(QWidget): self.setMouseTracking(True) def mouseMoveEvent(self, event): - if self.parent.bestFit: - event.ignore() # probably not needed + if self.parent.bestFit or event.buttons() != Qt.LeftButton: + event.ignore() return self.delta += (event.pos() - self.reference) * 1.0/self.scalefactor @@ -89,7 +102,7 @@ class ImageViewer(QWidget): def mouseReleaseEvent(self, event): if self.parent.bestFit: - event.ignore() # probably not needed + event.ignore() return self.app.restoreOverrideCursor() @@ -97,7 +110,7 @@ class ImageViewer(QWidget): def wheelEvent(self, event): if self.parent.bestFit: - event.ignore() # probably not needed + event.ignore() return if event.angleDelta().y() > 0: @@ -115,7 +128,6 @@ class ImageViewer(QWidget): def scale(self, factor): self.scalefactor = factor - print(f"ImaveViewer.scalefactor={self.scalefactor}") # self.label.resize(self.scalefactor * self.label.size()) self.update() @@ -287,6 +299,9 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) + self.referenceImage.mouseMoved.connect(self.selectedImage.slot_paint_event) + self.selectedImage.mouseMoved.connect(self.referenceImage.slot_paint_event) + def _update(self): if not self.app.model.selected_dupes: return @@ -374,7 +389,7 @@ class DetailsDialog(DetailsDialogBase): self.referenceImage.scale(self.scaleFactor) self.selectedImage.scale(self.scaleFactor) - self.buttonZoomIn.setEnabled(self.scaleFactor < 6.0) + self.buttonZoomIn.setEnabled(self.scaleFactor < 16.0) self.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) self.buttonBestFit.setEnabled(self.bestFit is False) self.buttonNormalSize.setEnabled(self.scaleFactor != 1.0) @@ -423,6 +438,8 @@ class DetailsDialog(DetailsDialogBase): def scale_to_bestfit(self): self.referenceImage.scale(self.scaleFactor) self.selectedImage.scale(self.scaleFactor) + self.referenceImage.setCenter() + self.selectedImage.setCenter() self._updateImages() @pyqtSlot() From a29f3fb4073315a2947636362d83e035ffa2767b Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 4 Jun 2020 17:52:46 +0200 Subject: [PATCH 07/61] only update delta when mouse is being dragged to reduce paint events --- qt/pe/details_dialog.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index a1caf4b5..fc019c23 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -44,6 +44,7 @@ class ImageViewer(QWidget): self.reference = QPointF() self.delta = QPointF() self.scalefactor = 1.0 + self.m_drag = False self.area = QScrollArea(parent) self.area.setBackgroundRole(QPalette.Dark) @@ -75,7 +76,7 @@ class ImageViewer(QWidget): painter.scale(self.scalefactor, self.scalefactor) painter.translate(self.delta) painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) - self.mouseMoved.emit(self.delta) + # print(f"paint event, delta={self.delta}") def setCenter(self): """ Resets origin """ @@ -86,24 +87,30 @@ class ImageViewer(QWidget): if self.parent.bestFit: event.ignore() return + if event.buttons() == Qt.LeftButton: + self.m_drag = True self.reference = event.pos() self.app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) def mouseMoveEvent(self, event): - if self.parent.bestFit or event.buttons() != Qt.LeftButton: + if self.parent.bestFit: event.ignore() return self.delta += (event.pos() - self.reference) * 1.0/self.scalefactor self.reference = event.pos() + if self.m_drag: + self.mouseMoved.emit(self.delta) self.update() def mouseReleaseEvent(self, event): if self.parent.bestFit: event.ignore() return + if event.buttons() == Qt.LeftButton: + m_drag = False self.app.restoreOverrideCursor() self.setMouseTracking(False) From 60ddb9b5964998dda29290e063998c7693d21d9f Mon Sep 17 00:00:00 2001 From: glubsy Date: Fri, 5 Jun 2020 22:39:16 +0200 Subject: [PATCH 08/61] Working synchronized views. --- qt/pe/details_dialog.py | 297 +++++++++++++++++----------------------- qt/pe/image_viewer.py | 141 +++++++++++++++++++ 2 files changed, 265 insertions(+), 173 deletions(-) create mode 100644 qt/pe/image_viewer.py diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index fc019c23..7348918d 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,8 +4,8 @@ # 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 Qt, QSize, QRectF, QPointF, pyqtSlot, pyqtSignal, QEvent -from PyQt5.QtGui import QPixmap, QIcon, QKeySequence, QPainter, QPalette +from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal +from PyQt5.QtGui import QPixmap, QIcon, QKeySequence from PyQt5.QtWidgets import ( QVBoxLayout, QAbstractItemView, @@ -18,9 +18,7 @@ from PyQt5.QtWidgets import ( QStyle, QAction, QWidget, - QScrollArea, QApplication, - QAbstractScrollArea ) from hscommon.trans import trget @@ -28,132 +26,16 @@ from hscommon import desktop from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from qtlib.util import createActions - +from qt.pe.image_viewer import ImageViewer tr = trget("ui") -class ImageViewer(QWidget): - """ Displays image and allow manipulations """ - mouseMoved = pyqtSignal(QPointF) - - def __init__(self, parent): - super().__init__(parent) - self.parent = parent - self.app = QApplication - self.pixmap = QPixmap() - self.m_rect = QRectF() - self.reference = QPointF() - self.delta = QPointF() - self.scalefactor = 1.0 - self.m_drag = False - - self.area = QScrollArea(parent) - self.area.setBackgroundRole(QPalette.Dark) - self.area.setWidgetResizable(True) - self.area.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) - # self.area.viewport().setAttribute(Qt.WA_StaticContents) - - self.label = QLabel() - 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) - self.label.setScaledContents(True) - - self.area.setWidget(self.label) - self.area.setVisible(False) - - @pyqtSlot(QPointF) - def slot_paint_event(self, delta): - self.delta = delta - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.translate(self.rect().center()) - painter.scale(self.scalefactor, self.scalefactor) - painter.translate(self.delta) - painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) - # print(f"paint event, delta={self.delta}") - - def setCenter(self): - """ Resets origin """ - self.delta = QPointF() - self.update() - - def mousePressEvent(self, event): - if self.parent.bestFit: - event.ignore() - return - if event.buttons() == Qt.LeftButton: - self.m_drag = True - - self.reference = event.pos() - self.app.setOverrideCursor(Qt.ClosedHandCursor) - self.setMouseTracking(True) - - def mouseMoveEvent(self, event): - if self.parent.bestFit: - event.ignore() - return - - self.delta += (event.pos() - self.reference) * 1.0/self.scalefactor - self.reference = event.pos() - if self.m_drag: - self.mouseMoved.emit(self.delta) - self.update() - - def mouseReleaseEvent(self, event): - if self.parent.bestFit: - event.ignore() - return - if event.buttons() == Qt.LeftButton: - m_drag = False - - self.app.restoreOverrideCursor() - self.setMouseTracking(False) - - def wheelEvent(self, event): - if self.parent.bestFit: - event.ignore() - return - - if event.angleDelta().y() > 0: - self.parent.zoomIn() - else: - self.parent.zoomOut() - - def setPixmap(self, pixmap): - self.pixmap = pixmap - if pixmap is None: - return - self.m_rect = self.pixmap.rect() - self.m_rect.translate(-self.m_rect.center()) - self.update() - - def scale(self, factor): - self.scalefactor = factor - # self.label.resize(self.scalefactor * self.label.size()) - self.update() - - def sizeHint(self): - return QSize(400, 400) - - @pyqtSlot() - def pixmapReset(self): - self.scalefactor = 1.0 - self.update() - - class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): super().__init__(parent, app) - self.selectedPixmap = None - self.referencePixmap = None - self.scaledSelectedPixmap = None - self.scaledReferencePixmap = None + self.selectedPixmap = QPixmap() + self.referencePixmap = QPixmap() + self.scaledSelectedPixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() self.scaleFactor = 1.0 self.bestFit = True @@ -215,7 +97,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) - self.selectedImage = ImageViewer(self) + self.selectedImage = ImageViewer(self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -280,7 +162,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) - self.referenceImage = ImageViewer(self) + self.referenceImage = ImageViewer(self, "referenceImage") # self.referenceImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -306,11 +188,12 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) - self.referenceImage.mouseMoved.connect(self.selectedImage.slot_paint_event) - self.selectedImage.mouseMoved.connect(self.referenceImage.slot_paint_event) + self.disable_buttons() def _update(self): + print("_update()") if not self.app.model.selected_dupes: + self.clear_all() return dupe = self.app.model.selected_dupes[0] group = self.app.model.results.get_group_of_duplicate(dupe) @@ -318,33 +201,43 @@ class DetailsDialog(DetailsDialogBase): self.resetState() self.selectedPixmap = QPixmap(str(dupe.path)) - if ref is dupe: - self.referencePixmap = None - self.scaledReferencePixmap = None + if ref is dupe: # currently selected file is the ref + self.referencePixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() self.buttonImgSwap.setEnabled(False) + # disable the blank widget. + self.disable_widget(self.referenceImage) else: self.referencePixmap = QPixmap(str(ref.path)) self.buttonImgSwap.setEnabled(True) + self.enable_widget(self.referenceImage) + + self.update_selected_widget() + self.update_reference_widget() self._updateImages() def _updateImages(self): target_size = None - if self.selectedPixmap is not None: + if self.selectedPixmap.isNull(): + # self.disable_widget(self.selectedImage, self.referenceImage) + pass + else: target_size = self.selectedImage.size() if not self.bestFit: # zoomed in state, expand self.scaledSelectedPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) - else: # best fit, keep ratio always + else: + # best fit, keep ratio always self.scaledSelectedPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.selectedImage.setPixmap(self.scaledSelectedPixmap) - # self.selectedImage.adjustSize() - else: - self.selectedImage.setPixmap(QPixmap()) - if self.referencePixmap is not None: + if self.referencePixmap.isNull(): + # self.disable_widget(self.referenceImage, self.selectedImage) + pass + else: # the selectedImage viewer widget sometimes ends up being bigger # than the referenceImage viewer, which distorts by one pixel the # scaled down pixmap for the reference, hence we'll reuse its size here. @@ -356,8 +249,90 @@ class DetailsDialog(DetailsDialogBase): self.scaledReferencePixmap = self.referencePixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.referenceImage.setPixmap(self.scaledReferencePixmap) + + def update_selected_widget(self): + print("update_selected_widget()") + if not self.selectedPixmap.isNull(): + self.enable_widget(self.selectedImage) + self.connect_signal(self.selectedImage, self.referenceImage) else: - self.referenceImage.setPixmap(QPixmap()) + self.disable_widget(self.selectedImage) + self.disconnect_signal(self.referenceImage) + + def update_reference_widget(self): + print("update_reference_widget()") + if not self.referencePixmap.isNull(): + self.enable_widget(self.referenceImage) + self.connect_signal(self.referenceImage, self.selectedImage) + else: + self.disable_widget(self.referenceImage) + self.disconnect_signal(self.selectedImage) + + def enable_widget(self, widget): + """We want to receive signals from the other_widget.""" + print(f"enable_widget({widget})") + if not widget.isEnabled(): + widget.setEnabled(True) + + def disable_widget(self, widget): + """Disables this widget and prevents receiving signals from other_widget.""" + print(f"disable_widget({widget})") + widget.setPixmap(QPixmap()) + widget.setDisabled(True) + + def connect_signal(self, widget, other_widget): + """We want this widget to send its signal to the other_widget.""" + print(f"connect_signal({widget}, {other_widget})") + if widget.connection is None: + if other_widget.isEnabled(): + widget.connection = widget.mouseMoved.connect(other_widget.slot_paint_event) + print(f"Connected signal from {widget} to slot of {other_widget}") + + def disconnect_signal(self, other_widget): + """We don't want this widget to send its signal anymore to the other_widget.""" + print(f"disconnect_signal({other_widget}") + if other_widget.connection: + other_widget.mouseMoved.disconnect() + other_widget.connection = None + print(f"Disconnected signal from {other_widget}") + + def resetState(self): + self.referencePixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() + self.selectedPixmap = QPixmap() + self.scaledSelectedPixmap = QPixmap() + self.buttonZoomIn.setEnabled(False) + self.buttonZoomOut.setEnabled(False) + self.buttonBestFit.setEnabled(False) # active mode by default + self.buttonNormalSize.setEnabled(True) + self.bestFit = True + self.scaleFactor = 1.0 + self.referenceImage.setCenter() + self.selectedImage.setCenter() + + def clear_all(self): + """No item from the model, disable and clear everything.""" + self.resetState() + + self.selectedPixmap = QPixmap() + self.scaledSelectedPixmap = QPixmap() + self.selectedImage.setPixmap(QPixmap()) + self.selectedImage.setDisabled(True) + + self.referencePixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() + self.referenceImage.setPixmap(QPixmap()) + self.referenceImage.setDisabled(True) + + self.buttonImgSwap.setDisabled(True) + self.buttonNormalSize.setDisabled(True) + + def disable_buttons(self): + self.buttonImgSwap.setEnabled(False) + self.buttonZoomIn.setEnabled(False) + self.buttonZoomOut.setEnabled(False) + self.buttonNormalSize.setEnabled(False) + self.buttonBestFit.setEnabled(False) # --- Override def resizeEvent(self, event): @@ -366,32 +341,22 @@ class DetailsDialog(DetailsDialogBase): self._updateImages() def show(self): + print("show()") DetailsDialogBase.show(self) self._update() # model --> view def refresh(self): + print("refresh()") DetailsDialogBase.refresh(self) if self.isVisible(): self._update() - def resetState(self): - self.scaledReferencePixmap = None - self.scaledSelectedPixmapPixmap = None - self.buttonZoomIn.setEnabled(False) - self.buttonZoomOut.setEnabled(False) - self.buttonBestFit.setEnabled(False) # active mode by default - self.buttonNormalSize.setEnabled(True) - self.bestFit = True - self.scaleFactor = 1.0 - + # ImageViewers def scaleImages(self, factor): self.scaleFactor *= factor - print(f'QDialog scaleFactor = {self.scaleFactor} (+factor {factor})') - # returns QSize, not good anymore - # self.referenceImage.scale(self.scaleFactor * self.referencePixmap.size()) - # self.selectedImage.scale(self.scaleFactor * self.selectedPixmap.size()) + print(f'QDialog scaleFactor = {self.scaleFactor} (+factor {factor})') self.referenceImage.scale(self.scaleFactor) self.selectedImage.scale(self.scaleFactor) @@ -403,14 +368,7 @@ class DetailsDialog(DetailsDialogBase): @pyqtSlot() def swapImages(self): - """ Swap pixmaps between ImageViewers """ - # self.horizontalLayout.replaceWidget(self.selectedImage, self.referenceImage) - - # self._tempPixmap = self.referencePixmap - # referencePixmap = self.selectedPixmap - # self.selectedPixmap = self._tempPixmap - # self._updateImages() - + """Swap pixmaps between ImageViewers.""" if self.bestFit: self.selectedImage.setPixmap(self.scaledReferencePixmap) self.referenceImage.setPixmap(self.scaledSelectedPixmap) @@ -423,7 +381,7 @@ class DetailsDialog(DetailsDialogBase): @pyqtSlot() def deswapImages(self): - """ Restore swapped pixmaps """ + """Restore swapped pixmaps between ImageViewers.""" if self.bestFit: self.selectedImage.setPixmap(self.scaledSelectedPixmap) self.referenceImage.setPixmap(self.scaledReferencePixmap) @@ -465,22 +423,15 @@ class DetailsDialog(DetailsDialogBase): self.scaleFactor = 1.0 self.selectedImage.setPixmap(self.selectedPixmap) - - if self.referencePixmap is None: - self.referenceImage.setPixmap(QPixmap()) - else: - self.referenceImage.setPixmap(self.referencePixmap) + self.referenceImage.setPixmap(self.referencePixmap) self.selectedImage.pixmapReset() self.referenceImage.pixmapReset() - # self.referenceImage.label.resize(self.scaleFactor * self.referencePixmap.size()) - # self.selectedImage.label.resize(self.scaleFactor * self.selectedPixmap.size()) - # self.referenceImage.label.adjustSize() - # self.selectedImage.label.adjustSize() + + self.update_selected_widget() + self.update_reference_widget() self.buttonNormalSize.setEnabled(False) self.buttonZoomIn.setEnabled(True) self.buttonZoomOut.setEnabled(True) self.buttonBestFit.setEnabled(True) - # self._updateImages() - diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py new file mode 100644 index 00000000..bdc18dc1 --- /dev/null +++ b/qt/pe/image_viewer.py @@ -0,0 +1,141 @@ +# 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 Qt, QSize, QRectF, QPointF, pyqtSlot, pyqtSignal, QEvent +from PyQt5.QtGui import QPixmap, QPainter, QPalette +from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, + QApplication, QAbstractScrollArea ) + +class ImageViewer(QWidget): + """Displays image and allows manipulations.""" + mouseMoved = pyqtSignal(QPointF) + + def __init__(self, parent, name=""): + super().__init__(parent) + self.parent = parent + self.app = QApplication + self.pixmap = QPixmap() + self.m_rect = QRectF() + self.reference = QPointF() + self.delta = QPointF() + self.scalefactor = 1.0 + self.drag = False + self.connection = None # signal bound to a slot + self.instance_name = name + + self.area = QScrollArea(parent) + self.area.setBackgroundRole(QPalette.Dark) + self.area.setWidgetResizable(True) + self.area.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + # self.area.viewport().setAttribute(Qt.WA_StaticContents) + + self.label = QLabel() + 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) + self.label.setScaledContents(True) + + self.area.setWidget(self.label) + self.area.setVisible(False) + + def __repr__(self): + return f'{self.instance_name}' + + @pyqtSlot(QPointF) + def slot_paint_event(self, delta): + self.delta = delta + self.update() + print(f"{self} received signal from {self.sender()}") + + def paintEvent(self, event): + painter = QPainter(self) + painter.translate(self.rect().center()) + painter.scale(self.scalefactor, self.scalefactor) + painter.translate(self.delta) + painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) + # print(f"{self} paintEvent delta={self.delta}") + + def setCenter(self): + """ Resets origin """ + self.delta = QPointF() + self.scalefactor = 1.0 + self.scale(self.scalefactor) + self.update() + + def changeEvent(self, event): + if event.type() == QEvent.EnabledChange: + print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") + + def mousePressEvent(self, event): + if self.parent.bestFit: + event.ignore() + return + if event.buttons() == Qt.LeftButton: + self.drag = True + + self.reference = event.pos() + self.app.setOverrideCursor(Qt.ClosedHandCursor) + self.setMouseTracking(True) + + def mouseMoveEvent(self, event): + if self.parent.bestFit: + event.ignore() + return + + self.delta += (event.pos() - self.reference) * 1.0/self.scalefactor + self.reference = event.pos() + if self.drag: + self.mouseMoved.emit(self.delta) + self.update() + + def mouseReleaseEvent(self, event): + if self.parent.bestFit: + event.ignore() + return + if event.buttons() == Qt.LeftButton: + drag = False + + self.app.restoreOverrideCursor() + self.setMouseTracking(False) + + def wheelEvent(self, event): + if self.parent.bestFit: + event.ignore() + return + + if event.angleDelta().y() > 0: + self.parent.zoomIn() + else: + self.parent.zoomOut() + + def setPixmap(self, pixmap): + if pixmap.isNull(): + if not self.pixmap.isNull(): + self.pixmap = pixmap + self.update() + return + elif not self.isEnabled(): + self.setEnabled(True) + self.pixmap = pixmap + self.m_rect = self.pixmap.rect() + self.m_rect.translate(-self.m_rect.center()) + self.update() + + def scale(self, factor): + self.scalefactor = factor + # self.label.resize(self.scalefactor * self.label.size()) + self.update() + + def sizeHint(self): + return QSize(400, 400) + + @pyqtSlot() + def pixmapReset(self): + """Called when the pixmap is set back to original size.""" + self.scalefactor = 1.0 + self.update() \ No newline at end of file From c3797918d20d9a96f94ef56aba2aebb7307073a7 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 10 Jun 2020 01:24:51 +0200 Subject: [PATCH 09/61] Controller class to decouple from the dialog class The controller singleton acts as a proxy to relay signals from each widget to the other It should help encapsulating things better if we need to use a different class for image viewers in the future. --- qt/pe/details_dialog.py | 260 ++++-------------- qt/pe/image_viewer.py | 565 +++++++++++++++++++++++++++++++++++----- 2 files changed, 552 insertions(+), 273 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 7348918d..a3a51641 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -6,38 +6,24 @@ from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal from PyQt5.QtGui import QPixmap, QIcon, QKeySequence -from PyQt5.QtWidgets import ( - QVBoxLayout, - QAbstractItemView, - QHBoxLayout, - QLabel, - QSizePolicy, - QToolBar, - QToolButton, - QGridLayout, - QStyle, - QAction, - QWidget, - QApplication, -) +from PyQt5.QtWidgets import (QVBoxLayout, QAbstractItemView, QHBoxLayout, + QLabel, QSizePolicy, QToolBar, QToolButton, QGridLayout, QStyle, QAction, + QWidget, QApplication ) from hscommon.trans import trget from hscommon import desktop from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from qtlib.util import createActions -from qt.pe.image_viewer import ImageViewer +from qt.pe.image_viewer import (QWidgetImageViewer, + QWidgetImageViewerController, QLabelImageViewerController) tr = trget("ui") class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): + self.vController = None super().__init__(parent, app) - self.selectedPixmap = QPixmap() - self.referencePixmap = QPixmap() - self.scaledSelectedPixmap = QPixmap() - self.scaledReferencePixmap = QPixmap() - self.scaleFactor = 1.0 - self.bestFit = True + def setupActions(self): # (name, shortcut, icon, desc, func) @@ -46,7 +32,7 @@ class DetailsDialog(DetailsDialogBase): # FIXME probably not used right now "actionSwap", QKeySequence.Backspace, - "swap", + "view-refresh", tr("Swap images"), self.swapImages, ), @@ -67,14 +53,14 @@ class DetailsDialog(DetailsDialogBase): ( "actionNormalSize", QKeySequence.Refresh, - "zoom-normal", + "zoom-original", tr("Normal size"), self.zoomNormalSize, ), ( "actionBestFit", tr("Ctrl+p"), - "zoom-reset", + "zoom-best-fit", tr("Best fit"), self.zoomBestFit, ) @@ -97,7 +83,8 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) - self.selectedImage = ImageViewer(self, "selectedImage") + self.selectedImageViewer = QWidgetImageViewer( + self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -109,7 +96,7 @@ class DetailsDialog(DetailsDialogBase): # self.selectedImage.setScaledContents(False) # self.selectedImage.setAlignment(Qt.AlignCenter) # # self.horizontalLayout.addWidget(self.selectedImage) - self.horizontalLayout.addWidget(self.selectedImage, 0, 0, 3, 1) + self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1) self.verticalToolBar = QToolBar(self) self.verticalToolBar.setOrientation(Qt.Orientation(2)) @@ -119,7 +106,7 @@ class DetailsDialog(DetailsDialogBase): self.buttonImgSwap = QToolButton(self.verticalToolBar) self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.buttonImgSwap.setIcon(QIcon.fromTheme('document-revert', \ + self.buttonImgSwap.setIcon(QIcon.fromTheme('view-refresh', \ self.style().standardIcon(QStyle.SP_BrowserReload))) self.buttonImgSwap.setText('Swap images') self.buttonImgSwap.setToolTip('Swap images') @@ -130,7 +117,7 @@ class DetailsDialog(DetailsDialogBase): self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) self.buttonZoomIn.setDefaultAction(self.actionZoomIn) self.buttonZoomIn.setText('ZoomIn') - self.buttonZoomIn.setIcon(QIcon.fromTheme(('zoom-in'), QIcon(":images/zoom-in.png"))) + self.buttonZoomIn.setIcon(QIcon.fromTheme('zoom-in')) self.buttonZoomOut = QToolButton(self.verticalToolBar) self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonIconOnly) @@ -162,7 +149,8 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) - self.referenceImage = ImageViewer(self, "referenceImage") + self.referenceImageViewer = QWidgetImageViewer( + self, "referenceImage") # self.referenceImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -172,7 +160,7 @@ class DetailsDialog(DetailsDialogBase): # ) # self.referenceImage.setSizePolicy(sizePolicy) # self.referenceImage.setAlignment(Qt.AlignCenter) - self.horizontalLayout.addWidget(self.referenceImage, 0, 2, 3, 1) + self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) # self.horizontalLayout.addWidget(self.referenceImage) self.verticalLayout.addLayout(self.horizontalLayout) self.tableView = DetailsTable(self) @@ -189,9 +177,22 @@ class DetailsDialog(DetailsDialogBase): self.verticalLayout.addWidget(self.tableView) self.disable_buttons() + # We use different types of controller depending on the + # underlying widgets we use to display images + # because their interface methods might differ + if isinstance(self.selectedImageViewer, QWidgetImageViewer): + self.vController = QWidgetImageViewerController( + self.selectedImageViewer, + self.referenceImageViewer, + self) + elif isinstance(self.selectedImageViewer, ScrollAreaImageViewer): + self.vController = ( + self.selectedImageViewer, + self.referenceImageViewer, + self) + def _update(self): - print("_update()") if not self.app.model.selected_dupes: self.clear_all() return @@ -199,135 +200,21 @@ class DetailsDialog(DetailsDialogBase): group = self.app.model.results.get_group_of_duplicate(dupe) ref = group.ref - self.resetState() - self.selectedPixmap = QPixmap(str(dupe.path)) - if ref is dupe: # currently selected file is the ref - self.referencePixmap = QPixmap() - self.scaledReferencePixmap = QPixmap() - self.buttonImgSwap.setEnabled(False) - # disable the blank widget. - self.disable_widget(self.referenceImage) - else: - self.referencePixmap = QPixmap(str(ref.path)) - self.buttonImgSwap.setEnabled(True) - self.enable_widget(self.referenceImage) - - self.update_selected_widget() - self.update_reference_widget() - - self._updateImages() + if self.vController is None: + return + self.vController.update(ref, dupe) def _updateImages(self): - target_size = None - if self.selectedPixmap.isNull(): - # self.disable_widget(self.selectedImage, self.referenceImage) - pass - else: - target_size = self.selectedImage.size() - if not self.bestFit: - # zoomed in state, expand - self.scaledSelectedPixmap = self.selectedPixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) - else: - # best fit, keep ratio always - self.scaledSelectedPixmap = self.selectedPixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.selectedImage.setPixmap(self.scaledSelectedPixmap) - - if self.referencePixmap.isNull(): - # self.disable_widget(self.referenceImage, self.selectedImage) - pass - else: - # the selectedImage viewer widget sometimes ends up being bigger - # than the referenceImage viewer, which distorts by one pixel the - # scaled down pixmap for the reference, hence we'll reuse its size here. - # target_size = self.selectedImage.size() - if not self.bestFit: - self.scaledReferencePixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) - else: - self.scaledReferencePixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.referenceImage.setPixmap(self.scaledReferencePixmap) - - def update_selected_widget(self): - print("update_selected_widget()") - if not self.selectedPixmap.isNull(): - self.enable_widget(self.selectedImage) - self.connect_signal(self.selectedImage, self.referenceImage) - else: - self.disable_widget(self.selectedImage) - self.disconnect_signal(self.referenceImage) - - def update_reference_widget(self): - print("update_reference_widget()") - if not self.referencePixmap.isNull(): - self.enable_widget(self.referenceImage) - self.connect_signal(self.referenceImage, self.selectedImage) - else: - self.disable_widget(self.referenceImage) - self.disconnect_signal(self.selectedImage) - - def enable_widget(self, widget): - """We want to receive signals from the other_widget.""" - print(f"enable_widget({widget})") - if not widget.isEnabled(): - widget.setEnabled(True) - - def disable_widget(self, widget): - """Disables this widget and prevents receiving signals from other_widget.""" - print(f"disable_widget({widget})") - widget.setPixmap(QPixmap()) - widget.setDisabled(True) - - def connect_signal(self, widget, other_widget): - """We want this widget to send its signal to the other_widget.""" - print(f"connect_signal({widget}, {other_widget})") - if widget.connection is None: - if other_widget.isEnabled(): - widget.connection = widget.mouseMoved.connect(other_widget.slot_paint_event) - print(f"Connected signal from {widget} to slot of {other_widget}") - - def disconnect_signal(self, other_widget): - """We don't want this widget to send its signal anymore to the other_widget.""" - print(f"disconnect_signal({other_widget}") - if other_widget.connection: - other_widget.mouseMoved.disconnect() - other_widget.connection = None - print(f"Disconnected signal from {other_widget}") - - def resetState(self): - self.referencePixmap = QPixmap() - self.scaledReferencePixmap = QPixmap() - self.selectedPixmap = QPixmap() - self.scaledSelectedPixmap = QPixmap() - self.buttonZoomIn.setEnabled(False) - self.buttonZoomOut.setEnabled(False) - self.buttonBestFit.setEnabled(False) # active mode by default - self.buttonNormalSize.setEnabled(True) - self.bestFit = True - self.scaleFactor = 1.0 - self.referenceImage.setCenter() - self.selectedImage.setCenter() + if not self.vController.bestFit: + return + self.vController._updateImages() def clear_all(self): """No item from the model, disable and clear everything.""" - self.resetState() - - self.selectedPixmap = QPixmap() - self.scaledSelectedPixmap = QPixmap() - self.selectedImage.setPixmap(QPixmap()) - self.selectedImage.setDisabled(True) - - self.referencePixmap = QPixmap() - self.scaledReferencePixmap = QPixmap() - self.referenceImage.setPixmap(QPixmap()) - self.referenceImage.setDisabled(True) - - self.buttonImgSwap.setDisabled(True) - self.buttonNormalSize.setDisabled(True) + self.vController.clear_all() def disable_buttons(self): + # FIXME Only called once at startup self.buttonImgSwap.setEnabled(False) self.buttonZoomIn.setEnabled(False) self.buttonZoomOut.setEnabled(False) @@ -336,102 +223,55 @@ class DetailsDialog(DetailsDialogBase): # --- Override def resizeEvent(self, event): - if not self.bestFit: + if self.vController is None: return self._updateImages() def show(self): - print("show()") DetailsDialogBase.show(self) self._update() # model --> view def refresh(self): - print("refresh()") DetailsDialogBase.refresh(self) if self.isVisible(): self._update() # ImageViewers def scaleImages(self, factor): - self.scaleFactor *= factor - - print(f'QDialog scaleFactor = {self.scaleFactor} (+factor {factor})') - - self.referenceImage.scale(self.scaleFactor) - self.selectedImage.scale(self.scaleFactor) - - self.buttonZoomIn.setEnabled(self.scaleFactor < 16.0) - self.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) - self.buttonBestFit.setEnabled(self.bestFit is False) - self.buttonNormalSize.setEnabled(self.scaleFactor != 1.0) + self.vController.scaleImages(factor) @pyqtSlot() def swapImages(self): """Swap pixmaps between ImageViewers.""" - if self.bestFit: - self.selectedImage.setPixmap(self.scaledReferencePixmap) - self.referenceImage.setPixmap(self.scaledSelectedPixmap) - else: - self.selectedImage.setPixmap(self.referencePixmap) - self.referenceImage.setPixmap(self.selectedPixmap) - + self.vController.swapImages() # swap the columns in the details table as well self.tableView.horizontalHeader().swapSections(1, 2) @pyqtSlot() def deswapImages(self): """Restore swapped pixmaps between ImageViewers.""" - if self.bestFit: - self.selectedImage.setPixmap(self.scaledSelectedPixmap) - self.referenceImage.setPixmap(self.scaledReferencePixmap) - else: - self.selectedImage.setPixmap(self.selectedPixmap) - self.referenceImage.setPixmap(self.referencePixmap) - + self.vController.deswapImages() + # deswap the columns in the details table as well self.tableView.horizontalHeader().swapSections(1, 2) @pyqtSlot() def zoomIn(self): - self.scaleImages(1.25) + self.vController.scaleImages(1.25) @pyqtSlot() def zoomOut(self): - self.scaleImages(0.8) + self.vController.scaleImages(0.8) @pyqtSlot() def scale_to_bestfit(self): - self.referenceImage.scale(self.scaleFactor) - self.selectedImage.scale(self.scaleFactor) - self.referenceImage.setCenter() - self.selectedImage.setCenter() - self._updateImages() + self.vController.scale_to_bestfit() @pyqtSlot() def zoomBestFit(self): - self.bestFit = True - self.scaleFactor = 1.0 - self.buttonBestFit.setEnabled(False) - self.buttonZoomOut.setEnabled(False) - self.buttonZoomIn.setEnabled(False) - self.buttonNormalSize.setEnabled(True) + self.vController.zoomBestFit() self.scale_to_bestfit() @pyqtSlot() def zoomNormalSize(self): - self.bestFit = False - self.scaleFactor = 1.0 - - self.selectedImage.setPixmap(self.selectedPixmap) - self.referenceImage.setPixmap(self.referencePixmap) - - self.selectedImage.pixmapReset() - self.referenceImage.pixmapReset() - - self.update_selected_widget() - self.update_reference_widget() - - self.buttonNormalSize.setEnabled(False) - self.buttonZoomIn.setEnabled(True) - self.buttonZoomOut.setEnabled(True) - self.buttonBestFit.setEnabled(True) + self.vController.zoomNormalSize() diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index bdc18dc1..f7b85c76 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -2,33 +2,427 @@ # 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 Qt, QSize, QRectF, QPointF, pyqtSlot, pyqtSignal, QEvent +from PyQt5.QtCore import QObject, Qt, QSize, QRectF, QPointF, pyqtSlot, pyqtSignal, QEvent from PyQt5.QtGui import QPixmap, QPainter, QPalette -from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, - QApplication, QAbstractScrollArea ) +from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, + QScrollBar, QApplication, QAbstractScrollArea ) -class ImageViewer(QWidget): +#TODO: fix panning while zoomed-in +#TODO: fix scroll area not showing up +#TODO: add keyboard shortcuts + +class BaseController(QObject): + """Base interface to keep image viewers synchronized. + Relays function calls. Singleton. """ + + def __init__(self, selectedViewer, referenceViewer, parent): + super().__init__() + self.selectedViewer = selectedViewer + self.referenceViewer = referenceViewer + self.selectedPixmap = QPixmap() + self.referencePixmap = QPixmap() + self.scaledSelectedPixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() + self.scaleFactor = 1.0 + self.bestFit = True + self.parent = parent #needed to change buttons' states + self._setupConnections() + + def _setupConnections(self): #virtual + pass + + def update(self, ref, dupe): + self.resetState() + self.selectedPixmap = QPixmap(str(dupe.path)) + if ref is dupe: # currently selected file is the ref + self.referencePixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() + self.parent.buttonImgSwap.setEnabled(False) + # disable the blank widget. + self.disable_widget(self.referenceViewer) + else: + self.referencePixmap = QPixmap(str(ref.path)) + self.parent.buttonImgSwap.setEnabled(True) + self.enable_widget(self.referenceViewer) + + self.update_selected_widget() + self.update_reference_widget() + + self._updateImages() + + def _updateImages(self): + target_size = None + if self.selectedPixmap.isNull(): + # self.disable_widget(self.selectedViewer, self.referenceViewer) + pass + else: + target_size = self.selectedViewer.size() + if not self.bestFit: + # zoomed in state, expand + self.scaledSelectedPixmap = self.selectedPixmap.scaled( + target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + else: + # best fit, keep ratio always + self.scaledSelectedPixmap = self.selectedPixmap.scaled( + target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.selectedViewer.setPixmap(self.scaledSelectedPixmap) + + if self.referencePixmap.isNull(): + # self.disable_widget(self.referenceViewer, self.selectedViewer) + pass + else: + # the selectedImage viewer widget sometimes ends up being bigger + # than the referenceImage viewer, which distorts by one pixel the + # scaled down pixmap for the reference, hence we'll reuse its size here. + # target_size = self.selectedViewer.size() + if not self.bestFit: + self.scaledReferencePixmap = self.referencePixmap.scaled( + target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + else: + self.scaledReferencePixmap = self.referencePixmap.scaled( + target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.referenceViewer.setPixmap(self.scaledReferencePixmap) + + @pyqtSlot(float) + def scaleImages(self, factor): + self.scaleFactor *= factor + print(f'Controller scaleFactor = \ + {self.scaleFactor} (+factor {factor})') + + self.parent.buttonZoomIn.setEnabled(self.scaleFactor < 16.0) + self.parent.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) + self.parent.buttonBestFit.setEnabled(self.bestFit is False) + self.parent.buttonNormalSize.setEnabled(self.scaleFactor != 1.0) + + def sefCenter(self): + #FIXME need specialization? + self.selectedViewer.setCenter() + self.referenceViewer.setCenter() + + def resetState(self): + self.selectedPixmap = QPixmap() + self.scaledSelectedPixmap = QPixmap() + self.referencePixmap = QPixmap() + self.scaledReferencePixmap = QPixmap() + + self.setBestFit(True) + self.scaleFactor = 1.0 + self.setCenter() + + self.parent.buttonZoomIn.setEnabled(False) + self.parent.buttonZoomOut.setEnabled(False) + self.parent.buttonBestFit.setEnabled(False) # active mode by default + self.parent.buttonNormalSize.setEnabled(True) + + def clear_all(self): + """No item from the model, disable and clear everything.""" + self.resetState() + self.selectedViewer.setPixmap(QPixmap()) + self.selectedViewer.setDisabled(True) + self.referenceViewer.setPixmap(QPixmap()) + self.referenceViewer.setDisabled(True) + + self.parent.buttonImgSwap.setDisabled(True) + self.parent.buttonNormalSize.setDisabled(True) + + def swapImages(self): + if self.bestFit: + self.selectedViewer.setPixmap(self.scaledReferencePixmap) + self.referenceViewer.setPixmap(self.scaledSelectedPixmap) + else: + self.selectedViewer.setPixmap(self.referencePixmap) + self.referenceViewer.setPixmap(self.selectedPixmap) + + def deswapImages(self): + if self.bestFit: + self.selectedViewer.setPixmap(self.scaledSelectedPixmap) + self.referenceViewer.setPixmap(self.scaledReferencePixmap) + else: + self.selectedViewer.setPixmap(self.selectedPixmap) + self.referenceViewer.setPixmap(self.referencePixmap) + + def zoomBestFit(self): + self.setBestFit(True) + self.scaleFactor = 1.0 + self.parent.buttonBestFit.setEnabled(False) + self.parent.buttonZoomOut.setEnabled(False) + self.parent.buttonZoomIn.setEnabled(False) + self.parent.buttonNormalSize.setEnabled(True) + + def zoomNormalSize(self): + self.setBestFit(False) + self.scaleFactor = 1.0 + + self.selectedViewer.setPixmap(self.selectedPixmap) + self.referenceViewer.setPixmap(self.referencePixmap) + + self.selectedViewer.pixmapReset() + self.referenceViewer.pixmapReset() + + self.update_selected_widget() + self.update_reference_widget() + + self.parent.buttonNormalSize.setEnabled(False) + self.parent.buttonZoomIn.setEnabled(True) + self.parent.buttonZoomOut.setEnabled(True) + self.parent.buttonBestFit.setEnabled(True) + + def setBestFit(self, value): + self.bestFit = value + self.selectedViewer.bestFit = value + self.referenceViewer.bestFit = value + + def setCenter(self): + self.selectedViewer.setCenter() + self.referenceViewer.setCenter() + + + def update_selected_widget(self): + print("update_selected_widget()") + if not self.selectedPixmap.isNull(): + self.enable_widget(self.selectedViewer) + self.connect_signal(self.selectedViewer, self.referenceViewer) + else: + self.disable_widget(self.selectedViewer) + self.disconnect_signal(self.referenceViewer) + + def update_reference_widget(self): + print("update_reference_widget()") + if not self.referencePixmap.isNull(): + self.enable_widget(self.referenceViewer) + self.connect_signal(self.referenceViewer, self.selectedViewer) + else: + self.disable_widget(self.referenceViewer) + self.disconnect_signal(self.selectedViewer) + + def enable_widget(self, widget): + """We want to receive signals from the other_widget.""" + print(f"enable_widget({widget})") + if not widget.isEnabled(): + widget.setEnabled(True) + + def disable_widget(self, widget): + """Disables this widget and prevents receiving signals from other_widget.""" + print(f"disable_widget({widget})") + widget.setPixmap(QPixmap()) + widget.setDisabled(True) + + def connect_signal(self, widget, other_widget): + """We want this widget to send its signal to the other_widget.""" + print(f"connect_signal({widget}, {other_widget})") + if widget.connection is None: + if other_widget.isEnabled(): + widget.connection = widget.mouseDragged.connect(other_widget.slot_paint_event) + print(f"Connected signal from {widget} to slot of {other_widget}") + + def disconnect_signal(self, other_widget): + """We don't want this widget to send its signal anymore to the other_widget.""" + print(f"disconnect_signal({other_widget}") + if other_widget.connection: + other_widget.mouseDragged.disconnect() + other_widget.connection = None + print(f"Disconnected signal from {other_widget}") + + +class QWidgetImageViewerController(BaseController): + def __init__(self, selectedViewer, referenceViewer, parent): + super().__init__(selectedViewer, referenceViewer, parent) + # self._setupConnections() + + def _setupConnections(self): + self.selectedViewer.mouseWheeled.connect( + self.scaleImages) + self.referenceViewer.mouseWheeled.connect( + self.scaleImages) + + def scale(self, factor): + self.selectedViewer.scale(factor) + self.referenceViewer.scale(factor) + + @pyqtSlot(float) + def scaleImages(self, factor): + super().scaleImages(factor) + # we scale the Qwidget itself in this case + self.selectedViewer.scale(self.scaleFactor) + self.referenceViewer.scale(self.scaleFactor) + + def scale_to_bestfit(self): + self.scale(1.0) + super().setCenter() + super()._updateImages() + + + + +class QLabelImageViewerController(BaseController): + def __init__(self, selectedViewer, referenceViewer, parent): + super().__init__(selectedViewer, referenceViewer, parent) + + def scale(self, factor): + pass #FIXME + + @pyqtSlot(float) + def scaleImages(self, factor): + super().scaleImages(factor) + # we scale the member Qlable in this case + self.selectedViewer.scale(self.scaleFactor) + self.referenceViewer.scale(self.scaleFactor) + +class GraphicsViewController(BaseController): + pass + + +class QWidgetImageViewer(QWidget): """Displays image and allows manipulations.""" - mouseMoved = pyqtSignal(QPointF) + mouseDragged = pyqtSignal(QPointF) + mouseWheeled = pyqtSignal(float) def __init__(self, parent, name=""): super().__init__(parent) - self.parent = parent - self.app = QApplication - self.pixmap = QPixmap() - self.m_rect = QRectF() - self.reference = QPointF() - self.delta = QPointF() - self.scalefactor = 1.0 - self.drag = False + self._app = QApplication + self._pixmap = QPixmap() + self._rect = QRectF() + self._reference = QPointF() + self._delta = QPointF() + self._scaleFactor = 1.0 + self._drag = False self.connection = None # signal bound to a slot - self.instance_name = name + self._instance_name = name + self.bestFit = True - self.area = QScrollArea(parent) - self.area.setBackgroundRole(QPalette.Dark) - self.area.setWidgetResizable(True) - self.area.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) - # self.area.viewport().setAttribute(Qt.WA_StaticContents) + # self.label = QLabel() + # 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) + # self.label.setScaledContents(True) + + # self.scrollarea = QScrollArea(self) + # self.scrollarea.setBackgroundRole(QPalette.Dark) + # self.scrollarea.setWidgetResizable(True) + # self.scrollarea.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + # # self.scrollarea.viewport().setAttribute(Qt.WA_StaticContents) + + # self.scrollarea.setWidget(self.label) + # self.scrollarea.setVisible(True) + + + def __repr__(self): + return f'{self._instance_name}' + + def paintEvent(self, event): + painter = QPainter(self) + painter.translate(self.rect().center()) + painter.scale(self._scaleFactor, self._scaleFactor) + painter.translate(self._delta) + painter.drawPixmap(self._rect.topLeft(), self._pixmap) + # print(f"{self} paintEvent delta={self._delta}") + + def setCenter(self): + """ Resets origin """ + self._delta = QPointF() + self._scaleFactor = 1.0 + self.scale(self._scaleFactor) + self.update() + + def changeEvent(self, event): + if event.type() == QEvent.EnabledChange: + print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") + + def mousePressEvent(self, event): + if self.bestFit: + event.ignore() + return + if event.buttons() == Qt.LeftButton: + self._drag = True + + self._reference = event.pos() + self._app.setOverrideCursor(Qt.ClosedHandCursor) + self.setMouseTracking(True) + + def mouseMoveEvent(self, event): + if self.bestFit: + event.ignore() + return + + self._delta += (event.pos() - self._reference) * 1.0/self._scaleFactor + self._reference = event.pos() + if self._drag: + self.mouseDragged.emit(self._delta) + self.update() + + def mouseReleaseEvent(self, event): + if self.bestFit: + event.ignore() + return + if event.buttons() == Qt.LeftButton: + drag = False + + self._app.restoreOverrideCursor() + self.setMouseTracking(False) + + def wheelEvent(self, event): + if self.bestFit: + event.ignore() + return + + if event.angleDelta().y() > 0: + self.mouseWheeled.emit(1.25) # zoom-in + else: + self.mouseWheeled.emit(0.8) # zoom-out + + def setPixmap(self, pixmap): + if pixmap.isNull(): + if not self._pixmap.isNull(): + self._pixmap = pixmap + self.update() + return + elif not self.isEnabled(): + self.setEnabled(True) + self._pixmap = pixmap + self._rect = self._pixmap.rect() + self._rect.translate(-self._rect.center()) + self.update() + + def scale(self, factor): + self._scaleFactor = factor + self.update() + + def sizeHint(self): + return QSize(400, 400) + + @pyqtSlot() + def pixmapReset(self): + """Called when the pixmap is set back to original size.""" + self._scaleFactor = 1.0 + self.update() + + @pyqtSlot(QPointF) + def slot_paint_event(self, delta): + self._delta = delta + self.update() + print(f"{self} received signal from {self.sender()}") + + +class ScrollAreaImageViewer(QScrollArea): + """Version with Qlabel for testing""" + mouseDragged = pyqtSignal(QPointF) + + def __init__(self, parent, name=""): + super().__init__(parent) + self._parent = parent + self._app = QApplication + self._pixmap = QPixmap() + self._rect = QRectF() + self._reference = QPointF() + self._delta = QPointF() + self._scaleFactor = 1.0 + self._drag = False + self.connection = None # signal bound to a slot + self._instance_name = name self.label = QLabel() sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) @@ -40,31 +434,31 @@ class ImageViewer(QWidget): self.label.setAlignment(Qt.AlignCenter) self.label.setScaledContents(True) - self.area.setWidget(self.label) - self.area.setVisible(False) + self.scrollarea = QScrollArea(self) + self.setBackgroundRole(QPalette.Dark) + self.setWidgetResizable(True) + self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) + # self.scrollarea.viewport().setAttribute(Qt.WA_StaticContents) + + self.setWidget(self.label) + self.setVisible(True) def __repr__(self): - return f'{self.instance_name}' - - @pyqtSlot(QPointF) - def slot_paint_event(self, delta): - self.delta = delta - self.update() - print(f"{self} received signal from {self.sender()}") + return f'{self._instance_name}' def paintEvent(self, event): painter = QPainter(self) painter.translate(self.rect().center()) - painter.scale(self.scalefactor, self.scalefactor) - painter.translate(self.delta) - painter.drawPixmap(self.m_rect.topLeft(), self.pixmap) - # print(f"{self} paintEvent delta={self.delta}") + painter.scale(self._scaleFactor, self._scaleFactor) + painter.translate(self._delta) + painter.drawPixmap(self._rect.topLeft(), self._pixmap) + # print(f"{self} paintEvent delta={self._delta}") def setCenter(self): """ Resets origin """ - self.delta = QPointF() - self.scalefactor = 1.0 - self.scale(self.scalefactor) + self._delta = QPointF() + self._scaleFactor = 1.0 + self.scale(self._scaleFactor) self.update() def changeEvent(self, event): @@ -72,70 +466,115 @@ class ImageViewer(QWidget): print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") def mousePressEvent(self, event): - if self.parent.bestFit: + if self._parent.bestFit: event.ignore() return if event.buttons() == Qt.LeftButton: - self.drag = True + self._drag = True - self.reference = event.pos() - self.app.setOverrideCursor(Qt.ClosedHandCursor) + self._reference = event.pos() + self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) def mouseMoveEvent(self, event): - if self.parent.bestFit: + if self._parent.bestFit: event.ignore() return - self.delta += (event.pos() - self.reference) * 1.0/self.scalefactor - self.reference = event.pos() - if self.drag: - self.mouseMoved.emit(self.delta) + self._delta += (event.pos() - self._reference) * 1.0/self._scaleFactor + self._reference = event.pos() + if self._drag: + self.mouseDragged.emit(self._delta) self.update() def mouseReleaseEvent(self, event): - if self.parent.bestFit: + if self._parent.bestFit: event.ignore() return if event.buttons() == Qt.LeftButton: drag = False - self.app.restoreOverrideCursor() + self._app.restoreOverrideCursor() self.setMouseTracking(False) def wheelEvent(self, event): - if self.parent.bestFit: + if self._parent.bestFit: event.ignore() return if event.angleDelta().y() > 0: - self.parent.zoomIn() + self._parent.zoomIn() else: - self.parent.zoomOut() + self._parent.zoomOut() def setPixmap(self, pixmap): - if pixmap.isNull(): - if not self.pixmap.isNull(): - self.pixmap = pixmap - self.update() - return - elif not self.isEnabled(): - self.setEnabled(True) - self.pixmap = pixmap - self.m_rect = self.pixmap.rect() - self.m_rect.translate(-self.m_rect.center()) + #FIXME refactored + # if pixmap.isNull(): + # if not self._pixmap.isNull(): + # self._pixmap = pixmap + # self.update() + # return + # elif not self.isEnabled(): + # self.setEnabled(True) + # self._pixmap = pixmap + self.label.setPixmap(pixmap) + self._rect = self._pixmap.rect() + self._rect.translate(-self._rect.center()) self.update() def scale(self, factor): - self.scalefactor = factor - # self.label.resize(self.scalefactor * self.label.size()) + self._scaleFactor = factor + self.label.resize(self._scaleFactor * self.label.pixmap().size()) + self.adjustScrollBar(self.scrollarea.horizontalScrollBar(), factor) + self.adjustScrollBar(self.scrollarea.verticalScrollBar(), factor) self.update() + def adjustScrollBar(self, scrollBar, factor): + scrollBar.setValue(int(factor * scrollBar.value() + ((factor - 1) * scrollBar.pageStep()/2))) + def sizeHint(self): return QSize(400, 400) @pyqtSlot() def pixmapReset(self): """Called when the pixmap is set back to original size.""" - self.scalefactor = 1.0 - self.update() \ No newline at end of file + self._scaleFactor = 1.0 + self.update() + + @pyqtSlot(QPointF) + def slot_paint_event(self, delta): + self._delta = delta + self.update() + print(f"{self} received signal from {self.sender()}") + + + + +from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem + +class SceneImageViewer(QGraphicsView): + """Re-Implementation test""" + + def __init__(self, parent): + super().__init__(parent) + self._scene = QGraphicsScene() + self._item = QGraphicsPixmapItem() + self.setScene(_scene) + self._scene.addItem(self.item) + self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setResizeAnchor(QGraphicsView.AnchorViewCenter) + + def setPixmap(self, pixmap): + self._item.setPixmap(pixmap) + offset = -QRectF(pixmap.rect()).center() + self._item.setOffset(offset) + self.setSceneRect(offset.x()*4, offset.y()*4, -offset.x()*8, -offset.y()*8) + self.translate(1, 1) + + def scale(self, factor): + self.scale(factor, factor) + + def sizeHint(): + return QSize(400, 400) From 8103cb3664db146410bd6b908aa5662774323e68 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 10 Jun 2020 18:23:48 +0200 Subject: [PATCH 10/61] Disable unused methods from controller * setPixmap() now disables the QWidget automatically if the pixmap passed is null. * the controller relays repaint events to the other widget --- qt/pe/details_dialog.py | 4 +- qt/pe/image_viewer.py | 181 ++++++++++++++++++++++------------------ 2 files changed, 100 insertions(+), 85 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index a3a51641..348d9e27 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -15,7 +15,7 @@ from hscommon import desktop from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from qtlib.util import createActions -from qt.pe.image_viewer import (QWidgetImageViewer, +from qt.pe.image_viewer import (QWidgetImageViewer, ScrollAreaImageViewer, QWidgetImageViewerController, QLabelImageViewerController) tr = trget("ui") @@ -186,7 +186,7 @@ class DetailsDialog(DetailsDialogBase): self.referenceImageViewer, self) elif isinstance(self.selectedImageViewer, ScrollAreaImageViewer): - self.vController = ( + self.vController = QLabelImageViewerController( self.selectedImageViewer, self.referenceImageViewer, self) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index f7b85c76..c802dde4 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -12,7 +12,7 @@ from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, #TODO: add keyboard shortcuts class BaseController(QObject): - """Base interface to keep image viewers synchronized. + """Base proxy interface to keep image viewers synchronized. Relays function calls. Singleton. """ def __init__(self, selectedViewer, referenceViewer, parent): @@ -26,6 +26,8 @@ class BaseController(QObject): self.scaleFactor = 1.0 self.bestFit = True self.parent = parent #needed to change buttons' states + self.selectedViewer.controller = self + self.referenceViewer.controller = self self._setupConnections() def _setupConnections(self): #virtual @@ -34,28 +36,25 @@ class BaseController(QObject): def update(self, ref, dupe): self.resetState() self.selectedPixmap = QPixmap(str(dupe.path)) - if ref is dupe: # currently selected file is the ref + if ref is dupe: # currently selected file is the actual reference file self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() self.parent.buttonImgSwap.setEnabled(False) # disable the blank widget. - self.disable_widget(self.referenceViewer) + self.referenceViewer.setPixmap(self.referencePixmap) else: self.referencePixmap = QPixmap(str(ref.path)) self.parent.buttonImgSwap.setEnabled(True) - self.enable_widget(self.referenceViewer) + # self.enable_widget(self.referenceViewer) - self.update_selected_widget() - self.update_reference_widget() + # self.update_selected_widget() + # self.update_reference_widget() self._updateImages() def _updateImages(self): target_size = None - if self.selectedPixmap.isNull(): - # self.disable_widget(self.selectedViewer, self.referenceViewer) - pass - else: + if not self.selectedPixmap.isNull(): target_size = self.selectedViewer.size() if not self.bestFit: # zoomed in state, expand @@ -67,10 +66,7 @@ class BaseController(QObject): target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.selectedViewer.setPixmap(self.scaledSelectedPixmap) - if self.referencePixmap.isNull(): - # self.disable_widget(self.referenceViewer, self.selectedViewer) - pass - else: + if not self.referencePixmap.isNull(): # the selectedImage viewer widget sometimes ends up being bigger # than the referenceImage viewer, which distorts by one pixel the # scaled down pixmap for the reference, hence we'll reuse its size here. @@ -156,12 +152,12 @@ class BaseController(QObject): self.selectedViewer.setPixmap(self.selectedPixmap) self.referenceViewer.setPixmap(self.referencePixmap) + # self.update_selected_widget() + # self.update_reference_widget() + self.selectedViewer.pixmapReset() self.referenceViewer.pixmapReset() - self.update_selected_widget() - self.update_reference_widget() - self.parent.buttonNormalSize.setEnabled(False) self.parent.buttonZoomIn.setEnabled(True) self.parent.buttonZoomOut.setEnabled(True) @@ -177,63 +173,65 @@ class BaseController(QObject): self.referenceViewer.setCenter() - def update_selected_widget(self): - print("update_selected_widget()") - if not self.selectedPixmap.isNull(): - self.enable_widget(self.selectedViewer) - self.connect_signal(self.selectedViewer, self.referenceViewer) - else: - self.disable_widget(self.selectedViewer) - self.disconnect_signal(self.referenceViewer) + # def update_selected_widget(self): + # print("update_selected_widget()") + # if not self.selectedPixmap.isNull(): + # self.enable_widget(self.selectedViewer) + # self.connect_signal(self.selectedViewer, self.referenceViewer) + # else: + # self.disable_widget(self.selectedViewer) + # self.disconnect_signal(self.referenceViewer) - def update_reference_widget(self): - print("update_reference_widget()") - if not self.referencePixmap.isNull(): - self.enable_widget(self.referenceViewer) - self.connect_signal(self.referenceViewer, self.selectedViewer) - else: - self.disable_widget(self.referenceViewer) - self.disconnect_signal(self.selectedViewer) + # def update_reference_widget(self): + # print("update_reference_widget()") + # if not self.referencePixmap.isNull(): + # self.enable_widget(self.referenceViewer) + # self.connect_signal(self.referenceViewer, self.selectedViewer) + # else: + # self.disable_widget(self.referenceViewer) + # self.disconnect_signal(self.selectedViewer) - def enable_widget(self, widget): - """We want to receive signals from the other_widget.""" - print(f"enable_widget({widget})") - if not widget.isEnabled(): - widget.setEnabled(True) + # def enable_widget(self, widget): + # if not widget.isEnabled(): + # widget.setEnabled(True) - def disable_widget(self, widget): - """Disables this widget and prevents receiving signals from other_widget.""" - print(f"disable_widget({widget})") - widget.setPixmap(QPixmap()) - widget.setDisabled(True) + # def disable_widget(self, widget): + # """Disables this widget and prevents receiving signals from other_widget.""" + # print(f"disable_widget({widget})") + # widget.setPixmap(QPixmap()) + # widget.setDisabled(True) - def connect_signal(self, widget, other_widget): - """We want this widget to send its signal to the other_widget.""" - print(f"connect_signal({widget}, {other_widget})") - if widget.connection is None: - if other_widget.isEnabled(): - widget.connection = widget.mouseDragged.connect(other_widget.slot_paint_event) - print(f"Connected signal from {widget} to slot of {other_widget}") + # def connect_signal(self, widget, other_widget): + # """We want this widget to send its signal to the other_widget.""" + # print(f"connect_signal({widget}, {other_widget})") + # if widget.connection is None: + # if other_widget.isEnabled(): + # widget.connection = widget.mouseDragged.connect(other_widget.slot_paint_event) + # print(f"Connected signal from {widget} to slot of {other_widget}") + + # def disconnect_signal(self, other_widget): + # """We don't want this widget to send its signal anymore to the other_widget.""" + # print(f"disconnect_signal({other_widget}") + # if other_widget.connection: + # other_widget.mouseDragged.disconnect() + # other_widget.connection = None + # print(f"Disconnected signal from {other_widget}") - def disconnect_signal(self, other_widget): - """We don't want this widget to send its signal anymore to the other_widget.""" - print(f"disconnect_signal({other_widget}") - if other_widget.connection: - other_widget.mouseDragged.disconnect() - other_widget.connection = None - print(f"Disconnected signal from {other_widget}") class QWidgetImageViewerController(BaseController): + """Specialized version for QWidget-based viewers""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) # self._setupConnections() def _setupConnections(self): - self.selectedViewer.mouseWheeled.connect( - self.scaleImages) - self.referenceViewer.mouseWheeled.connect( - self.scaleImages) + # self.selectedViewer._wheelConnection = \ + # self.selectedViewer.mouseWheeled.connect(self.scaleImages) + # self.referenceViewer._wheelConnection = \ + # self.referenceViewer.mouseWheeled.connect(self.scaleImages) + self.selectedViewer.connect_signals() + self.referenceViewer.connect_signals() def scale(self, factor): self.selectedViewer.scale(factor) @@ -251,10 +249,17 @@ class QWidgetImageViewerController(BaseController): super().setCenter() super()._updateImages() + @pyqtSlot(QPointF) + def slot_paint_event(self, delta): + if self.sender() is self.referenceViewer: + self.selectedViewer.slot_paint_event(delta) + else: + self.referenceViewer.slot_paint_event(delta) class QLabelImageViewerController(BaseController): + """Specialized version fro QLabel-based viewers""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) @@ -269,6 +274,8 @@ class QLabelImageViewerController(BaseController): self.referenceViewer.scale(self.scaleFactor) class GraphicsViewController(BaseController): + """Specialized version fro QGraphicsView-based viewers""" + #TODO pass @@ -286,29 +293,11 @@ class QWidgetImageViewer(QWidget): self._delta = QPointF() self._scaleFactor = 1.0 self._drag = False - self.connection = None # signal bound to a slot + self._dragConnection = None + self._wheelConnection = None self._instance_name = name self.bestFit = True - - # self.label = QLabel() - # 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) - # self.label.setScaledContents(True) - - # self.scrollarea = QScrollArea(self) - # self.scrollarea.setBackgroundRole(QPalette.Dark) - # self.scrollarea.setWidgetResizable(True) - # self.scrollarea.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) - # # self.scrollarea.viewport().setAttribute(Qt.WA_StaticContents) - - # self.scrollarea.setWidget(self.label) - # self.scrollarea.setVisible(True) - + self.controller = None def __repr__(self): return f'{self._instance_name}' @@ -331,6 +320,10 @@ class QWidgetImageViewer(QWidget): def changeEvent(self, event): if event.type() == QEvent.EnabledChange: print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") + if self.isEnabled(): + self.connect_signals() + return + self.disconnect_signals() def mousePressEvent(self, event): if self.bestFit: @@ -378,15 +371,36 @@ class QWidgetImageViewer(QWidget): if pixmap.isNull(): if not self._pixmap.isNull(): self._pixmap = pixmap + self.disconnect_signals() self.update() return elif not self.isEnabled(): + self.connect_signals() self.setEnabled(True) self._pixmap = pixmap self._rect = self._pixmap.rect() self._rect.translate(-self._rect.center()) self.update() + def isActive(self): + return True if not self.pixmap.isNull() else False + + def disconnect_signals(self): + if self._dragConnection: + self.mouseDragged.disconnect() + self._dragConnection = None + if self._wheelConnection: + self.mouseWheeled.disconnect() + self._wheelConnection = None + + def connect_signals(self): + if not self._dragConnection: + self._dragConnection = self.mouseDragged.connect( + self.controller.slot_paint_event) + if not self._wheelConnection: + self._wheelConnection = self.mouseWheeled.connect( + self.controller.scaleImages) + def scale(self, factor): self._scaleFactor = factor self.update() @@ -404,7 +418,7 @@ class QWidgetImageViewer(QWidget): def slot_paint_event(self, delta): self._delta = delta self.update() - print(f"{self} received signal from {self.sender()}") + # print(f"{self} received drag signal from {self.sender()}") class ScrollAreaImageViewer(QScrollArea): @@ -423,6 +437,7 @@ class ScrollAreaImageViewer(QScrollArea): self._drag = False self.connection = None # signal bound to a slot self._instance_name = name + self.controller = None self.label = QLabel() sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) From b7abcf2989ba9a2f73a41692cd84a0f3a47c5d83 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 10 Jun 2020 22:02:08 +0200 Subject: [PATCH 11/61] Use native QPixmap swap() method instead of manual setPixmap() When swapping images, use getters to hopefully get a reference to each pixmap and swap them within a single slot. --- qt/pe/details_dialog.py | 24 +++++++----------------- qt/pe/image_viewer.py | 33 +++++++++++++++++---------------- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 348d9e27..1bc0645e 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -176,7 +176,12 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) - self.disable_buttons() + self.buttonImgSwap.setEnabled(False) + self.buttonZoomIn.setEnabled(False) + self.buttonZoomOut.setEnabled(False) + self.buttonNormalSize.setEnabled(False) + self.buttonBestFit.setEnabled(False) + # We use different types of controller depending on the # underlying widgets we use to display images # because their interface methods might differ @@ -213,14 +218,6 @@ class DetailsDialog(DetailsDialogBase): """No item from the model, disable and clear everything.""" self.vController.clear_all() - def disable_buttons(self): - # FIXME Only called once at startup - self.buttonImgSwap.setEnabled(False) - self.buttonZoomIn.setEnabled(False) - self.buttonZoomOut.setEnabled(False) - self.buttonNormalSize.setEnabled(False) - self.buttonBestFit.setEnabled(False) - # --- Override def resizeEvent(self, event): if self.vController is None: @@ -244,17 +241,10 @@ class DetailsDialog(DetailsDialogBase): @pyqtSlot() def swapImages(self): """Swap pixmaps between ImageViewers.""" - self.vController.swapImages() + self.vController.swapPixmaps() # swap the columns in the details table as well self.tableView.horizontalHeader().swapSections(1, 2) - @pyqtSlot() - def deswapImages(self): - """Restore swapped pixmaps between ImageViewers.""" - self.vController.deswapImages() - # deswap the columns in the details table as well - self.tableView.horizontalHeader().swapSections(1, 2) - @pyqtSlot() def zoomIn(self): self.vController.scaleImages(1.25) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index c802dde4..a0c787ea 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -19,10 +19,13 @@ class BaseController(QObject): super().__init__() self.selectedViewer = selectedViewer self.referenceViewer = referenceViewer + + # cached pixmaps self.selectedPixmap = QPixmap() self.referencePixmap = QPixmap() self.scaledSelectedPixmap = QPixmap() self.scaledReferencePixmap = QPixmap() + self.scaleFactor = 1.0 self.bestFit = True self.parent = parent #needed to change buttons' states @@ -121,22 +124,13 @@ class BaseController(QObject): self.parent.buttonImgSwap.setDisabled(True) self.parent.buttonNormalSize.setDisabled(True) - def swapImages(self): - if self.bestFit: - self.selectedViewer.setPixmap(self.scaledReferencePixmap) - self.referenceViewer.setPixmap(self.scaledSelectedPixmap) - else: - self.selectedViewer.setPixmap(self.referencePixmap) - self.referenceViewer.setPixmap(self.selectedPixmap) - - def deswapImages(self): - if self.bestFit: - self.selectedViewer.setPixmap(self.scaledSelectedPixmap) - self.referenceViewer.setPixmap(self.scaledReferencePixmap) - else: - self.selectedViewer.setPixmap(self.selectedPixmap) - self.referenceViewer.setPixmap(self.referencePixmap) + @pyqtSlot() + def swapPixmaps(self): + self.selectedViewer.getPixmap().swap(self.referenceViewer.getPixmap()) + self.selectedViewer.center_and_update() + self.referenceViewer.center_and_update() + @pyqtSlot() def zoomBestFit(self): self.setBestFit(True) self.scaleFactor = 1.0 @@ -145,6 +139,7 @@ class BaseController(QObject): self.parent.buttonZoomIn.setEnabled(False) self.parent.buttonNormalSize.setEnabled(True) + @pyqtSlot() def zoomNormalSize(self): self.setBestFit(False) self.scaleFactor = 1.0 @@ -302,6 +297,9 @@ class QWidgetImageViewer(QWidget): def __repr__(self): return f'{self._instance_name}' + def getPixmap(self): + return self._pixmap + def paintEvent(self, event): painter = QPainter(self) painter.translate(self.rect().center()) @@ -378,6 +376,9 @@ class QWidgetImageViewer(QWidget): self.connect_signals() self.setEnabled(True) self._pixmap = pixmap + self.center_and_update() + + def center_and_update(self): self._rect = self._pixmap.rect() self._rect.translate(-self._rect.center()) self.update() @@ -538,7 +539,7 @@ class ScrollAreaImageViewer(QScrollArea): self.update() def scale(self, factor): - self._scaleFactor = factor + self._scaleFactor *= factor self.label.resize(self._scaleFactor * self.label.pixmap().size()) self.adjustScrollBar(self.scrollarea.horizontalScrollBar(), factor) self.adjustScrollBar(self.scrollarea.verticalScrollBar(), factor) From a706d0ebe5ae02385955f202eda9bc379bfcbc7a Mon Sep 17 00:00:00 2001 From: glubsy Date: Tue, 16 Jun 2020 21:14:28 +0200 Subject: [PATCH 12/61] Implement mostly working ScrollArea viewer Using a QWidget inside the QScrollArea mostly works but we only move around the pixmap inside the QWidget, not the QWidget itself, which doesn't update scrollbars. Need a better implementation. --- qt/pe/details_dialog.py | 44 +-- qt/pe/image_viewer.py | 827 ++++++++++++++++++++++++++++------------ 2 files changed, 598 insertions(+), 273 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 1bc0645e..d2282efe 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -15,8 +15,9 @@ from hscommon import desktop from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from qtlib.util import createActions -from qt.pe.image_viewer import (QWidgetImageViewer, ScrollAreaImageViewer, - QWidgetImageViewerController, QLabelImageViewerController) +from qt.pe.image_viewer import ( + QWidgetImageViewer, ScrollAreaImageViewer, GraphicsViewViewer, + QWidgetController, ScrollAreaController, GraphicsViewController) tr = trget("ui") class DetailsDialog(DetailsDialogBase): @@ -83,7 +84,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(2,1) self.horizontalLayout.setSpacing(4) - self.selectedImageViewer = QWidgetImageViewer( + self.selectedImageViewer = ScrollAreaImageViewer( self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) @@ -111,7 +112,7 @@ class DetailsDialog(DetailsDialogBase): self.buttonImgSwap.setText('Swap images') self.buttonImgSwap.setToolTip('Swap images') self.buttonImgSwap.pressed.connect(self.swapImages) - self.buttonImgSwap.released.connect(self.deswapImages) + self.buttonImgSwap.released.connect(self.swapImages) self.buttonZoomIn = QToolButton(self.verticalToolBar) self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) @@ -149,7 +150,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) - self.referenceImageViewer = QWidgetImageViewer( + self.referenceImageViewer = ScrollAreaImageViewer( self, "referenceImage") # self.referenceImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) @@ -186,20 +187,25 @@ class DetailsDialog(DetailsDialogBase): # underlying widgets we use to display images # because their interface methods might differ if isinstance(self.selectedImageViewer, QWidgetImageViewer): - self.vController = QWidgetImageViewerController( + self.vController = QWidgetController( self.selectedImageViewer, self.referenceImageViewer, self) elif isinstance(self.selectedImageViewer, ScrollAreaImageViewer): - self.vController = QLabelImageViewerController( + self.vController = ScrollAreaController( + self.selectedImageViewer, + self.referenceImageViewer, + self) + elif isinstance(self.selectedImageViewer, GraphicsViewViewer): + self.vController = GraphicsViewController( self.selectedImageViewer, self.referenceImageViewer, self) - def _update(self): if not self.app.model.selected_dupes: - self.clear_all() + # No item from the model, disable and clear everything. + self.vController.clear_all() return dupe = self.app.model.selected_dupes[0] group = self.app.model.results.get_group_of_duplicate(dupe) @@ -214,14 +220,11 @@ class DetailsDialog(DetailsDialogBase): return self.vController._updateImages() - def clear_all(self): - """No item from the model, disable and clear everything.""" - self.vController.clear_all() - # --- Override def resizeEvent(self, event): if self.vController is None: return + # update scaled down pixmaps self._updateImages() def show(self): @@ -235,32 +238,23 @@ class DetailsDialog(DetailsDialogBase): self._update() # ImageViewers - def scaleImages(self, factor): - self.vController.scaleImages(factor) - @pyqtSlot() def swapImages(self): - """Swap pixmaps between ImageViewers.""" self.vController.swapPixmaps() # swap the columns in the details table as well self.tableView.horizontalHeader().swapSections(1, 2) @pyqtSlot() def zoomIn(self): - self.vController.scaleImages(1.25) + self.vController.zoom_in() @pyqtSlot() def zoomOut(self): - self.vController.scaleImages(0.8) - - @pyqtSlot() - def scale_to_bestfit(self): - self.vController.scale_to_bestfit() + self.vController.zoom_out() @pyqtSlot() def zoomBestFit(self): - self.vController.zoomBestFit() - self.scale_to_bestfit() + self.vController.scale_to_bestfit() @pyqtSlot() def zoomNormalSize(self): diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index a0c787ea..90474b10 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -2,13 +2,12 @@ # 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, pyqtSlot, pyqtSignal, QEvent -from PyQt5.QtGui import QPixmap, QPainter, QPalette +from PyQt5.QtCore import QObject, Qt, QSize, QRectF, QPointF, QPoint, pyqtSlot, pyqtSignal, QEvent +from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, QScrollBar, QApplication, QAbstractScrollArea ) -#TODO: fix panning while zoomed-in -#TODO: fix scroll area not showing up +#TODO QWidget version: fix panning while zoomed-in #TODO: add keyboard shortcuts class BaseController(QObject): @@ -26,15 +25,18 @@ class BaseController(QObject): self.scaledSelectedPixmap = QPixmap() self.scaledReferencePixmap = QPixmap() - self.scaleFactor = 1.0 + 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() - def _setupConnections(self): #virtual - pass + def _setupConnections(self): + self.selectedViewer.connect_signals() + self.referenceViewer.connect_signals() def update(self, ref, dupe): self.resetState() @@ -44,14 +46,10 @@ class BaseController(QObject): self.scaledReferencePixmap = QPixmap() self.parent.buttonImgSwap.setEnabled(False) # disable the blank widget. - self.referenceViewer.setPixmap(self.referencePixmap) + self.referenceViewer.setImage(self.referencePixmap) else: self.referencePixmap = QPixmap(str(ref.path)) self.parent.buttonImgSwap.setEnabled(True) - # self.enable_widget(self.referenceViewer) - - # self.update_selected_widget() - # self.update_reference_widget() self._updateImages() @@ -67,7 +65,8 @@ class BaseController(QObject): # best fit, keep ratio always self.scaledSelectedPixmap = self.selectedPixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.selectedViewer.setPixmap(self.scaledSelectedPixmap) + self.selectedViewer.setImage(self.scaledSelectedPixmap) + self.selectedViewer.center_and_update() if not self.referencePixmap.isNull(): # the selectedImage viewer widget sometimes ends up being bigger @@ -80,23 +79,34 @@ class BaseController(QObject): else: self.scaledReferencePixmap = self.referencePixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.referenceViewer.setPixmap(self.scaledReferencePixmap) + self.referenceViewer.setImage(self.scaledReferencePixmap) + self.referenceViewer.center_and_update() - @pyqtSlot(float) - def scaleImages(self, factor): - self.scaleFactor *= factor - print(f'Controller scaleFactor = \ - {self.scaleFactor} (+factor {factor})') + def zoom_in(self): + self.scaleImages(True) - self.parent.buttonZoomIn.setEnabled(self.scaleFactor < 16.0) - self.parent.buttonZoomOut.setEnabled(self.scaleFactor > 1.0) + def zoom_out(self): + self.scaleImages(False) + + @pyqtSlot(bool) # True = zoom-in + def scaleImages(self, zoom_type): + + 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.buttonBestFit.setEnabled(self.bestFit is False) - self.parent.buttonNormalSize.setEnabled(self.scaleFactor != 1.0) - - def sefCenter(self): - #FIXME need specialization? - self.selectedViewer.setCenter() - self.referenceViewer.setCenter() + self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) def resetState(self): self.selectedPixmap = QPixmap() @@ -105,8 +115,10 @@ class BaseController(QObject): self.scaledReferencePixmap = QPixmap() self.setBestFit(True) - self.scaleFactor = 1.0 - self.setCenter() + self.current_scale = 1.0 + + self.selectedViewer.resetCenter() + self.referenceViewer.resetCenter() self.parent.buttonZoomIn.setEnabled(False) self.parent.buttonZoomOut.setEnabled(False) @@ -116,39 +128,46 @@ class BaseController(QObject): def clear_all(self): """No item from the model, disable and clear everything.""" self.resetState() - self.selectedViewer.setPixmap(QPixmap()) + self.selectedViewer.setImage(self.selectedPixmap) # null self.selectedViewer.setDisabled(True) - self.referenceViewer.setPixmap(QPixmap()) + self.referenceViewer.setImage(self.referencePixmap) # null self.referenceViewer.setDisabled(True) - self.parent.buttonImgSwap.setDisabled(True) self.parent.buttonNormalSize.setDisabled(True) @pyqtSlot() - def swapPixmaps(self): - self.selectedViewer.getPixmap().swap(self.referenceViewer.getPixmap()) - self.selectedViewer.center_and_update() - self.referenceViewer.center_and_update() - - @pyqtSlot() - def zoomBestFit(self): + def scale_to_bestfit(self): + """Setup before scaling to bestfit""" self.setBestFit(True) - self.scaleFactor = 1.0 + self.current_scale = 1.0 + + self.selectedViewer.scaleBy(1.0) + self.referenceViewer.scaleBy(1.0) + + self.selectedViewer.resetCenter() + self.referenceViewer.resetCenter() + self._updateImages() + self.parent.buttonBestFit.setEnabled(False) self.parent.buttonZoomOut.setEnabled(False) self.parent.buttonZoomIn.setEnabled(False) self.parent.buttonNormalSize.setEnabled(True) + def setBestFit(self, value): + self.bestFit = value + self.selectedViewer.bestFit = value + self.referenceViewer.bestFit = value + @pyqtSlot() def zoomNormalSize(self): self.setBestFit(False) - self.scaleFactor = 1.0 + self.current_scale = 1.0 - self.selectedViewer.setPixmap(self.selectedPixmap) - self.referenceViewer.setPixmap(self.referencePixmap) + self.selectedViewer.setImage(self.selectedPixmap) + self.referenceViewer.setImage(self.referencePixmap) - # self.update_selected_widget() - # self.update_reference_widget() + self.selectedViewer.center_and_update() + self.referenceViewer.center_and_update() self.selectedViewer.pixmapReset() self.referenceViewer.pixmapReset() @@ -158,126 +177,82 @@ class BaseController(QObject): self.parent.buttonZoomOut.setEnabled(True) self.parent.buttonBestFit.setEnabled(True) - def setBestFit(self, value): - self.bestFit = value - self.selectedViewer.bestFit = value - self.referenceViewer.bestFit = value + def syncCenters(self): # virtual + pass - def setCenter(self): - self.selectedViewer.setCenter() - self.referenceViewer.setCenter() - - - # def update_selected_widget(self): - # print("update_selected_widget()") - # if not self.selectedPixmap.isNull(): - # self.enable_widget(self.selectedViewer) - # self.connect_signal(self.selectedViewer, self.referenceViewer) - # else: - # self.disable_widget(self.selectedViewer) - # self.disconnect_signal(self.referenceViewer) - - # def update_reference_widget(self): - # print("update_reference_widget()") - # if not self.referencePixmap.isNull(): - # self.enable_widget(self.referenceViewer) - # self.connect_signal(self.referenceViewer, self.selectedViewer) - # else: - # self.disable_widget(self.referenceViewer) - # self.disconnect_signal(self.selectedViewer) - - # def enable_widget(self, widget): - # if not widget.isEnabled(): - # widget.setEnabled(True) - - # def disable_widget(self, widget): - # """Disables this widget and prevents receiving signals from other_widget.""" - # print(f"disable_widget({widget})") - # widget.setPixmap(QPixmap()) - # widget.setDisabled(True) - - # def connect_signal(self, widget, other_widget): - # """We want this widget to send its signal to the other_widget.""" - # print(f"connect_signal({widget}, {other_widget})") - # if widget.connection is None: - # if other_widget.isEnabled(): - # widget.connection = widget.mouseDragged.connect(other_widget.slot_paint_event) - # print(f"Connected signal from {widget} to slot of {other_widget}") - - # def disconnect_signal(self, other_widget): - # """We don't want this widget to send its signal anymore to the other_widget.""" - # print(f"disconnect_signal({other_widget}") - # if other_widget.connection: - # other_widget.mouseDragged.disconnect() - # other_widget.connection = None - # print(f"Disconnected signal from {other_widget}") + def swapPixmaps(self): #virtual + pass -class QWidgetImageViewerController(BaseController): +class QWidgetController(BaseController): """Specialized version for QWidget-based viewers""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) - # self._setupConnections() - - def _setupConnections(self): - # self.selectedViewer._wheelConnection = \ - # self.selectedViewer.mouseWheeled.connect(self.scaleImages) - # self.referenceViewer._wheelConnection = \ - # self.referenceViewer.mouseWheeled.connect(self.scaleImages) - self.selectedViewer.connect_signals() - self.referenceViewer.connect_signals() - - def scale(self, factor): - self.selectedViewer.scale(factor) - self.referenceViewer.scale(factor) - - @pyqtSlot(float) - def scaleImages(self, factor): - super().scaleImages(factor) - # we scale the Qwidget itself in this case - self.selectedViewer.scale(self.scaleFactor) - self.referenceViewer.scale(self.scaleFactor) - - def scale_to_bestfit(self): - self.scale(1.0) - super().setCenter() - super()._updateImages() @pyqtSlot(QPointF) - def slot_paint_event(self, delta): + def onDraggedMouse(self, delta): if self.sender() is self.referenceViewer: - self.selectedViewer.slot_paint_event(delta) + self.selectedViewer.onDraggedMouse(delta) else: - self.referenceViewer.slot_paint_event(delta) + self.referenceViewer.onDraggedMouse(delta) + + @pyqtSlot() + def swapPixmaps(self): + self.selectedViewer.getPixmap().swap(self.referenceViewer.getPixmap()) + self.selectedViewer.center_and_update() + self.referenceViewer.center_and_update() -class QLabelImageViewerController(BaseController): + +class ScrollAreaController(BaseController): """Specialized version fro QLabel-based viewers""" def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) - def scale(self, factor): - pass #FIXME + @pyqtSlot(QPointF) + def onDraggedMouse(self, delta): + if self.sender() is self.referenceViewer: + self.selectedViewer.onDraggedMouse(delta) + else: + self.referenceViewer.onDraggedMouse(delta) + + @pyqtSlot() + def swapPixmaps(self): + self.referenceViewer._pixmap.swap(self.selectedViewer._pixmap) + self.referenceViewer.setCachedPixmap() + self.selectedViewer.setCachedPixmap() + + @pyqtSlot() + def syncCenters(self): + self.selectedViewer.setCenter(self.referenceViewer.getCenter()) + self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + - @pyqtSlot(float) - def scaleImages(self, factor): - super().scaleImages(factor) - # we scale the member Qlable in this case - self.selectedViewer.scale(self.scaleFactor) - self.referenceViewer.scale(self.scaleFactor) class GraphicsViewController(BaseController): """Specialized version fro QGraphicsView-based viewers""" - #TODO - pass + 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()) class QWidgetImageViewer(QWidget): - """Displays image and allows manipulations.""" + """Uses a QPixmap as the center piece.""" mouseDragged = pyqtSignal(QPointF) - mouseWheeled = pyqtSignal(float) + mouseWheeled = pyqtSignal(bool) def __init__(self, parent, name=""): super().__init__(parent) @@ -286,13 +261,15 @@ class QWidgetImageViewer(QWidget): self._rect = QRectF() self._reference = QPointF() self._delta = QPointF() - self._scaleFactor = 1.0 + self._scaleFactor = 1.3 + 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}' @@ -303,16 +280,15 @@ class QWidgetImageViewer(QWidget): def paintEvent(self, event): painter = QPainter(self) painter.translate(self.rect().center()) - painter.scale(self._scaleFactor, self._scaleFactor) + painter.scale(self._current_scale, self._current_scale) painter.translate(self._delta) painter.drawPixmap(self._rect.topLeft(), self._pixmap) - # print(f"{self} paintEvent delta={self._delta}") + # print(f"{self} paintEvent delta={self._delta} current scale={self._current_scale}") - def setCenter(self): + def resetCenter(self): """ Resets origin """ - self._delta = QPointF() - self._scaleFactor = 1.0 - self.scale(self._scaleFactor) + self._delta = QPointF() # FIXME does this even work? + self.scaleBy(1.0) self.update() def changeEvent(self, event): @@ -327,30 +303,35 @@ class QWidgetImageViewer(QWidget): if self.bestFit: event.ignore() return - if event.buttons() == Qt.LeftButton: + if event.button() == Qt.LeftButton: self._drag = True + else: + self._drag = False + event.ignore() + return self._reference = event.pos() self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) + event.accept() def mouseMoveEvent(self, event): if self.bestFit: event.ignore() return - self._delta += (event.pos() - self._reference) * 1.0/self._scaleFactor + self._delta += (event.pos() - self._reference) * 1.0 / self._current_scale self._reference = event.pos() if self._drag: self.mouseDragged.emit(self._delta) - self.update() + self.update() def mouseReleaseEvent(self, event): if self.bestFit: event.ignore() return - if event.buttons() == Qt.LeftButton: - drag = False + if event.button() == Qt.LeftButton: + self._drag = False self._app.restoreOverrideCursor() self.setMouseTracking(False) @@ -361,11 +342,11 @@ class QWidgetImageViewer(QWidget): return if event.angleDelta().y() > 0: - self.mouseWheeled.emit(1.25) # zoom-in + self.mouseWheeled.emit(True) # zoom-in else: - self.mouseWheeled.emit(0.8) # zoom-out + self.mouseWheeled.emit(False) # zoom-out - def setPixmap(self, pixmap): + def setImage(self, pixmap): if pixmap.isNull(): if not self._pixmap.isNull(): self._pixmap = pixmap @@ -373,19 +354,26 @@ class QWidgetImageViewer(QWidget): self.update() return elif not self.isEnabled(): - self.connect_signals() self.setEnabled(True) + self.connect_signals() self._pixmap = pixmap - self.center_and_update() def center_and_update(self): self._rect = self._pixmap.rect() self._rect.translate(-self._rect.center()) self.update() - def isActive(self): + 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() @@ -394,16 +382,16 @@ class QWidgetImageViewer(QWidget): self.mouseWheeled.disconnect() self._wheelConnection = None - def connect_signals(self): - if not self._dragConnection: - self._dragConnection = self.mouseDragged.connect( - self.controller.slot_paint_event) - if not self._wheelConnection: - self._wheelConnection = self.mouseWheeled.connect( - self.controller.scaleImages) + def zoom_in(self): + self._current_scale *= 1.25 + self.update() - def scale(self, factor): - self._scaleFactor = factor + def zoom_out(self): + self._current_scale *= 0.8 + self.update() + + def scaleBy(self, factor): + self._current_scale = factor self.update() def sizeHint(self): @@ -412,185 +400,528 @@ class QWidgetImageViewer(QWidget): @pyqtSlot() def pixmapReset(self): """Called when the pixmap is set back to original size.""" - self._scaleFactor = 1.0 + self._current_scale = 1.0 self.update() @pyqtSlot(QPointF) - def slot_paint_event(self, delta): + def onDraggedMouse(self, delta): self._delta = delta self.update() - # print(f"{self} received drag signal from {self.sender()}") + print(f"{self} received drag signal from {self.sender()}") + + + +class QLabelNoAA(QLabel): + 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) + # 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"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) + + def sizeHint(self): + # if self._current_scale <= 1.0: + # return self._pixmap.size() + return self._pixmap.size() * self._current_scale class ScrollAreaImageViewer(QScrollArea): """Version with Qlabel for testing""" mouseDragged = pyqtSignal(QPointF) + mouseWheeled = pyqtSignal(bool) def __init__(self, parent, name=""): super().__init__(parent) self._parent = parent self._app = QApplication self._pixmap = QPixmap() + self._scaledpixmap = None self._rect = QRectF() self._reference = QPointF() self._delta = QPointF() - self._scaleFactor = 1.0 + self._scaleFactor = 1.3 + self._current_scale = 1.0 self._drag = False - self.connection = None # signal bound to a slot + self._dragConnection = None + self._wheelConnection = None self._instance_name = name + self.wantScrollBars = True + self.bestFit = True self.controller = None - self.label = QLabel() - 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) - self.label.setScaledContents(True) + 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.scrollarea = QScrollArea(self) self.setBackgroundRole(QPalette.Dark) - self.setWidgetResizable(True) + self.setWidgetResizable(False) self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) - # self.scrollarea.viewport().setAttribute(Qt.WA_StaticContents) + # self.viewport().setAttribute(Qt.WA_StaticContents) + self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + + if self.wantScrollBars: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + else: + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 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 paintEvent(self, event): - painter = QPainter(self) - painter.translate(self.rect().center()) - painter.scale(self._scaleFactor, self._scaleFactor) - painter.translate(self._delta) - painter.drawPixmap(self._rect.topLeft(), self._pixmap) - # print(f"{self} paintEvent delta={self._delta}") + def getPixmap(self): + return self._pixmap - def setCenter(self): - """ Resets origin """ - self._delta = QPointF() - self._scaleFactor = 1.0 - self.scale(self._scaleFactor) - self.update() + 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 changeEvent(self, event): if event.type() == QEvent.EnabledChange: print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") def mousePressEvent(self, event): - if self._parent.bestFit: + if self.bestFit: event.ignore() return - if event.buttons() == Qt.LeftButton: + if event.button() == Qt.LeftButton: self._drag = True + else: + self._drag = False + event.ignore() + return self._reference = event.pos() self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) + event.accept() def mouseMoveEvent(self, event): - if self._parent.bestFit: + if self.bestFit: event.ignore() return - self._delta += (event.pos() - self._reference) * 1.0/self._scaleFactor + self._delta += (event.pos() - self._reference) * 1.0/self._current_scale self._reference = event.pos() if self._drag: self.mouseDragged.emit(self._delta) - self.update() + self.label._delta = self._delta + self.label.update() def mouseReleaseEvent(self, event): - if self._parent.bestFit: + if self.bestFit: event.ignore() return - if event.buttons() == Qt.LeftButton: - drag = False + if event.button() == Qt.LeftButton: + self._drag = False self._app.restoreOverrideCursor() self.setMouseTracking(False) def wheelEvent(self, event): - if self._parent.bestFit: + if self.bestFit: event.ignore() return if event.angleDelta().y() > 0: - self._parent.zoomIn() + self.mouseWheeled.emit(True) # zoom-in else: - self._parent.zoomOut() + self.mouseWheeled.emit(False) # zoom-out - def setPixmap(self, pixmap): - #FIXME refactored - # if pixmap.isNull(): - # if not self._pixmap.isNull(): - # self._pixmap = pixmap - # self.update() - # return - # elif not self.isEnabled(): - # self.setEnabled(True) - # self._pixmap = pixmap + def setImage(self, pixmap, cache=True): + if pixmap.isNull(): + if not self._pixmap.isNull(): + self._pixmap = pixmap + self.update() + return + elif not self.isEnabled(): + self.setEnabled(True) + self.connect_signals() + + self._pixmap = pixmap self.label.setPixmap(pixmap) - self._rect = self._pixmap.rect() - self._rect.translate(-self._rect.center()) - self.update() + self.label.adjustSize() + + def center_and_update(self): + self._rect = self.rect() + self._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.setPixmap(self._pixmap) + self.label.update() + + 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}") + # 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) + + # 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()) + + + + + # we might need a QRect here to update? + self.label._current_scale = factor + self.label.update() + + + + self.label.adjustSize() # needed to center view on zoom change + + if self.wantScrollBars: + self.adjustScrollBar(self.horizontalScrollBar(), factor) + self.adjustScrollBar(self.verticalScrollBar(), factor) - def scale(self, factor): - self._scaleFactor *= factor - self.label.resize(self._scaleFactor * self.label.pixmap().size()) - self.adjustScrollBar(self.scrollarea.horizontalScrollBar(), factor) - self.adjustScrollBar(self.scrollarea.verticalScrollBar(), factor) - self.update() def adjustScrollBar(self, scrollBar, factor): - scrollBar.setValue(int(factor * scrollBar.value() + ((factor - 1) * scrollBar.pageStep()/2))) + # 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)) + + # self.viewport().update() + + def resetCenter(self): + """ Resets origin """ + self._delta = QPointF() + self.label._delta = self._delta + self._current_scale = 1.0 + self.scaleBy(1.0) + # self.label.update() # already called in scaleBy + + def setCenter(self, point): + self._reference = point + + def getCenter(self): + return self._reference def sizeHint(self): - return QSize(400, 400) + return self._pixmap.rect().size() + + def viewportSizeHint(self): + return self._pixmap.rect().size() @pyqtSlot() def pixmapReset(self): """Called when the pixmap is set back to original size.""" - self._scaleFactor = 1.0 - self.update() + self._current_scale = 1.0 + self.scaleBy(1.0) + # self.ensureWidgetVisible(self.label) # might not need + self.label.update() @pyqtSlot(QPointF) - def slot_paint_event(self, delta): + def onDraggedMouse(self, delta): + # This updates position from mouse delta from other panel self._delta = delta - self.update() - print(f"{self} received signal from {self.sender()}") + self.label._delta = delta + self.label.update() + print(f"{self} received mouse drag signal from {self.sender()}") from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem -class SceneImageViewer(QGraphicsView): - """Re-Implementation test""" +class GraphicsViewViewer(QGraphicsView): + """Re-Implementation.""" + mouseDragged = pyqtSignal(QPointF) + mouseWheeled = pyqtSignal(bool) - def __init__(self, parent): + def __init__(self, parent, name=""): super().__init__(parent) + self._parent = parent + self._app = QApplication + self._pixmap = QPixmap() + self._scaledpixmap = None + self._rect = QRectF() + self._reference = QPointF() + self._delta = QPointF() + self._scaleFactor = 1.3 + 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() + + # specific to this class self._scene = QGraphicsScene() self._item = QGraphicsPixmapItem() - self.setScene(_scene) - self._scene.addItem(self.item) + self.setScene(self._scene) + self._scene.addItem(self._item) self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + if self.wantScrollBars: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setResizeAnchor(QGraphicsView.AnchorViewCenter) + self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + # self.setViewportUpdateMode (QGraphicsView.FullViewportUpdate) - def setPixmap(self, pixmap): + 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 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._reference = event.pos() + self._app.setOverrideCursor(Qt.ClosedHandCursor) + self.setMouseTracking(True) + 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() + if self._drag: + self.mouseDragged.emit(self._delta) + self.label.update() + 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 + + if event.angleDelta().y() > 0: + self.mouseWheeled.emit(True) # zoom-in + else: + self.mouseWheeled.emit(False) # zoom-out + + + def setImage(self, pixmap): + self._pixmap = pixmap self._item.setPixmap(pixmap) - offset = -QRectF(pixmap.rect()).center() - self._item.setOffset(offset) - self.setSceneRect(offset.x()*4, offset.y()*4, -offset.x()*8, -offset.y()*8) + # offset = -QRectF(pixmap.rect()).center() + # 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()) - def scale(self, factor): - self.scale(factor, factor) + def scaleBy(self, factor): + # super().scale(factor, factor) + self.zoom(factor) + + def resetCenter(self): + # """ Resets origin """ + # self._delta = QPointF() + # self._scaleFactor = 1.0 + # self.scale(self._scaleFactor) + # self.update() + pass + + def setNewCenter(self, position): + self._centerPoint = position + self.centerOn(self._centerPoint) + + @pyqtSlot() + def pixmapReset(self): + """Called when the pixmap is set back to original size.""" + self.scaleBy(1.0) + + 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 viewportSizeHint(self): + return self._item.rect().size() + + def zoom_in(self): + self.zoom(self._scaleFactor) + + def zoom_out(self): + self.zoom(1.0 / self._scaleFactor) + + def zoom(self, factor): + #Get the position of the mouse before scaling, in scene coords + pointBeforeScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos()))) + + #Get the original screen centerpoint + screenCenter = self.mapToScene( self.rect().center() ) + + super().scale(factor, factor) + + #Get the position after scaling, in scene coords + pointAfterScale = QPointF( self.mapToScene( self.mapFromGlobal(QCursor.pos()) ) ) + + #Get the offset of how the screen moved + offset = QPointF( pointBeforeScale - pointAfterScale) + + #Adjust to the new center for correct zooming + newCenter = QPointF(screenCenter + offset) + self.setNewCenter(newCenter) + + # self.updateSceneRect(self._item.rect()) # TEST THIS? + + # mouse position has changed!! + # emit mouseMoved( QGraphicsView::mapToScene( event->pos() ) ); + # emit mouseMoved( QGraphicsView::mapToScene( mapFromGlobal(QCursor::pos()) ) ); + # emit somethingChanged(); - def sizeHint(): - return QSize(400, 400) From 970bb5e19dc481928f5b7b785914f0c8b01a8d99 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sun, 21 Jun 2020 01:49:17 +0200 Subject: [PATCH 13/61] 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) From aa79b31aaeef843fe6767e3957434225507b05d2 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sun, 21 Jun 2020 20:51:06 +0200 Subject: [PATCH 14/61] Work around resizing down offset by 1 pixel. --- qt/pe/details_dialog.py | 32 ++++++++++++++++++++------------ qt/pe/image_viewer.py | 13 +++++++------ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 3c5c7102..7a7c9cad 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -68,15 +68,15 @@ class DetailsDialog(DetailsDialogBase): self.verticalLayout = QVBoxLayout(self) self.verticalLayout.setSpacing(0) 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.setColumnMinimumWidth(1, 10) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setColumnStretch(0,24) + self.horizontalLayout.setColumnStretch(1,1) + self.horizontalLayout.setColumnStretch(2,24) # self.horizontalLayout.setColumnStretch(3,0) - self.horizontalLayout.setSpacing(2) + self.horizontalLayout.setSpacing(1) self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") # self.selectedImage = QLabel(self) @@ -96,6 +96,7 @@ class DetailsDialog(DetailsDialogBase): # 1, 3, 1, 1, Qt.Alignment(Qt.AlignRight)) self.verticalToolBar = QToolBar(self) + # self.verticalToolBar.setMaximumWidth(10) self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) # self.subVLayout = QVBoxLayout(self) # self.subVLayout.addWidget(self.verticalToolBar) @@ -144,7 +145,6 @@ class DetailsDialog(DetailsDialogBase): self.verticalToolBar.addWidget(self.buttonBestFit) self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) - # self.horizontalLayout.addWidget(self.verticalToolBar, Qt.AlignVCenter) self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") # self.referenceImage = QLabel(self) @@ -157,7 +157,6 @@ class DetailsDialog(DetailsDialogBase): # self.referenceImageViewer.setSizePolicy(sizePolicy) # self.referenceImageViewer.setAlignment(Qt.AlignCenter) self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) - # self.horizontalLayout.addWidget(self.referenceImageViewer) self.verticalLayout.addLayout(self.horizontalLayout) self.tableView = DetailsTable(self) @@ -172,7 +171,7 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) - self.tableView.hide() + # self.tableView.hide() self.buttonImgSwap.setEnabled(False) self.buttonZoomIn.setEnabled(False) @@ -215,14 +214,23 @@ class DetailsDialog(DetailsDialogBase): # --- Override def resizeEvent(self, event): # 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(). + # due to the toolbar in the middle keeping the same width, + # so resizing in the GridLayout's engine leads to not enough space + # left for the panel on the right. + # This ensures same size while shrinking at least: self.horizontalLayout.setColumnMinimumWidth( 0, self.selectedImageViewer.size().width()) self.horizontalLayout.setColumnMinimumWidth( 2, self.selectedImageViewer.size().width()) + # This works when expanding but it's ugly: +# if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): +# print(f"Before selected size: {self.selectedImageViewer.size()}\n\ +# Before reference size: {self.referenceImageViewer.size()}") +# self.selectedImageViewer.resize(self.referenceImageViewer.size()) +# print(f"After selected size: {self.selectedImageViewer.size()}\n\ +# After reference size: {self.referenceImageViewer.size()}") + if self.vController is None or not self.vController.bestFit: return # Only update the scaled down pixmaps diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 1deba4b3..f2fa523e 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -7,9 +7,6 @@ from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, QScrollBar, QApplication, QAbstractScrollArea ) -#TODO QWidget version: fix panning while zoomed-in -#TODO: add keyboard shortcuts - class BaseController(QObject): """Abstract Base class. Singleton. Base proxy interface to keep image viewers synchronized. @@ -235,9 +232,11 @@ class ScrollAreaController(BaseController): @pyqtSlot(int) def onVScrollBarChanged(self, value): if self.sender() is self.referenceViewer: - self.selectedViewer._verticalScrollBar.setValue(value) + if not self.selectedViewer.ignore_signal: + self.selectedViewer._verticalScrollBar.setValue(value) else: - self.referenceViewer._verticalScrollBar.setValue(value) + if not self.referenceViewer.ignore_signal: + self.referenceViewer._verticalScrollBar.setValue(value) @pyqtSlot(int) def onHScrollBarChanged(self, value): @@ -277,6 +276,8 @@ class GraphicsViewController(BaseController): class QWidgetImageViewer(QWidget): """Use a QPixmap, but no scrollbars.""" + #FIXME: panning while zoomed-in is broken (due to delta not interpolated right?) + #TODO: keyboard shortcuts for navigation mouseDragged = pyqtSignal(QPointF) mouseWheeled = pyqtSignal(float) @@ -429,7 +430,7 @@ class QWidgetImageViewer(QWidget): def onDraggedMouse(self, delta): self._mousePanningDelta = delta self.update() - print(f"{self} received drag signal from {self.sender()}") + # print(f"{self} received drag signal from {self.sender()}") class ScalablePixmap(QWidget): From 977c20f7c47cafc3220361257b1640852ca990b6 Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 22 Jun 2020 03:36:36 +0200 Subject: [PATCH 15/61] Add QSplitter to hide TableView in DetailsDialog --- qt/details_dialog.py | 4 ++-- qt/pe/details_dialog.py | 50 +++++++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/qt/details_dialog.py b/qt/details_dialog.py index d117f098..57cc650b 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -7,12 +7,12 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog +from PyQt5.QtWidgets import QMainWindow from .details_table import DetailsModel -class DetailsDialog(QDialog): +class DetailsDialog(QMainWindow): def __init__(self, parent, app, **kwargs): super().__init__(parent, Qt.Tool, **kwargs) self.app = app diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 7a7c9cad..c47aabd7 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -8,7 +8,7 @@ from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal from PyQt5.QtGui import QPixmap, QIcon, QKeySequence from PyQt5.QtWidgets import (QLayout, QVBoxLayout, QAbstractItemView, QHBoxLayout, QLabel, QSizePolicy, QToolBar, QToolButton, QGridLayout, QStyle, QAction, - QWidget, QApplication, QSpacerItem ) + QWidget, QApplication, QSpacerItem, QSplitter, QFrame ) from hscommon.trans import trget from hscommon import desktop @@ -25,7 +25,6 @@ class DetailsDialog(DetailsDialogBase): self.vController = None super().__init__(parent, app) - def setupActions(self): # (name, shortcut, icon, desc, func) ACTIONS = [ @@ -63,11 +62,17 @@ class DetailsDialog(DetailsDialogBase): def _setupUi(self): self.setupActions() self.setWindowTitle(tr("Details")) - self.resize(502, 295) - self.setMinimumSize(QSize(250, 250)) - self.verticalLayout = QVBoxLayout(self) - self.verticalLayout.setSpacing(0) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.resize(502, 502) + self.setMinimumSize(QSize(500, 500)) + + # self.verticalLayout = QVBoxLayout(self) + # self.verticalLayout.setSpacing(0) + # self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.splitter = QSplitter(Qt.Vertical, self) + self.setCentralWidget(self.splitter) + self.topFrame = QFrame() + self.topFrame.setFrameShape(QFrame.StyledPanel) + self.horizontalLayout = QGridLayout() # Minimum width for the toolbar in the middle: self.horizontalLayout.setColumnMinimumWidth(1, 10) @@ -157,20 +162,33 @@ class DetailsDialog(DetailsDialogBase): # self.referenceImageViewer.setSizePolicy(sizePolicy) # self.referenceImageViewer.setAlignment(Qt.AlignCenter) self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) - self.verticalLayout.addLayout(self.horizontalLayout) + # self.verticalLayout.addLayout(self.horizontalLayout) + self.topFrame.setLayout(self.horizontalLayout) + self.splitter.addWidget(self.topFrame) + + # container = QWidget(self) + # container.setLayout(self.horizontalLayout) + # self.setLayout(self.horizontalLayout) + # self.splitter.addWidget(self) + self.splitter.setStretchFactor(0, 8) self.tableView = DetailsTable(self) - sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth()) + # sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth()) self.tableView.setSizePolicy(sizePolicy) - self.tableView.setMinimumSize(QSize(0, 188)) - self.tableView.setMaximumSize(QSize(16777215, 190)) + # self.tableView.setMinimumSize(QSize(0, 190)) + # self.tableView.setMaximumSize(QSize(16777215, 190)) + self.tableView.setAlternatingRowColors(True) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) - self.verticalLayout.addWidget(self.tableView) + # self.verticalLayout.addLayout(self.tableView) + + self.splitter.addWidget(self.tableView) + self.splitter.setStretchFactor(1, 1) + # self.tableView.hide() self.buttonImgSwap.setEnabled(False) @@ -207,7 +225,7 @@ class DetailsDialog(DetailsDialogBase): group = self.app.model.results.get_group_of_duplicate(dupe) ref = group.ref - if self.vController is None: + if self.vController is None: # Not yet constructed! return self.vController.update(ref, dupe) @@ -237,6 +255,10 @@ class DetailsDialog(DetailsDialogBase): self.vController._updateImages() def show(self): + # Compute the maximum size the table view can reach + self.tableView.setMaximumHeight( + self.tableView.rowHeight(1) * self.tableModel.model.row_count()\ + + self.tableView.verticalHeader().sectionSize(0)) DetailsDialogBase.show(self) self._update() From 011939f5eefb4dbd033b6ed75d948e42320680a0 Mon Sep 17 00:00:00 2001 From: glubsy Date: Tue, 23 Jun 2020 05:03:56 +0200 Subject: [PATCH 16/61] Keep scale accross files of the same dupe group. * Also fix scaled down pixmap when updating pixmap in the same group * Fix ignoring mouse wheel event when max scale has been reached * Fix toggle scrollbars when asking for normal size --- qt/pe/details_dialog.py | 12 ++-- qt/pe/image_viewer.py | 148 +++++++++++++++++++++++++--------------- 2 files changed, 98 insertions(+), 62 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index c47aabd7..8d9ff198 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -63,7 +63,7 @@ class DetailsDialog(DetailsDialogBase): self.setupActions() self.setWindowTitle(tr("Details")) self.resize(502, 502) - self.setMinimumSize(QSize(500, 500)) + self.setMinimumSize(QSize(250, 250)) # self.verticalLayout = QVBoxLayout(self) # self.verticalLayout.setSpacing(0) @@ -83,7 +83,7 @@ class DetailsDialog(DetailsDialogBase): # self.horizontalLayout.setColumnStretch(3,0) self.horizontalLayout.setSpacing(1) - self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") + self.selectedImageViewer = QWidgetImageViewer(self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -151,7 +151,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) - self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") + self.referenceImageViewer = QWidgetImageViewer(self, "referenceImage") # self.referenceImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -199,7 +199,7 @@ class DetailsDialog(DetailsDialogBase): # We use different types of controller depending on the # underlying widgets we use to display images - # because their interface methods might differ + # because their interface and methods might differ if isinstance(self.selectedImageViewer, QWidgetImageViewer): self.vController = QWidgetController( self.selectedImageViewer, @@ -227,7 +227,7 @@ class DetailsDialog(DetailsDialogBase): if self.vController is None: # Not yet constructed! return - self.vController.update(ref, dupe) + self.vController.updateView(ref, dupe, group) # --- Override def resizeEvent(self, event): @@ -252,7 +252,7 @@ class DetailsDialog(DetailsDialogBase): if self.vController is None or not self.vController.bestFit: return # Only update the scaled down pixmaps - self.vController._updateImages() + self.vController.updateBothImages() def show(self): # Compute the maximum size the table view can reach diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index f2fa523e..3c80b017 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -7,6 +7,9 @@ from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, QScrollBar, QApplication, QAbstractScrollArea ) +MAX_SCALE = 12.0 +MIN_SCALE = 0.1 + class BaseController(QObject): """Abstract Base class. Singleton. Base proxy interface to keep image viewers synchronized. @@ -28,13 +31,21 @@ class BaseController(QObject): self.bestFit = True self.wantScrollBars = True self.parent = parent #To change buttons' states + self.cached_group = None def _setupConnections(self): self.selectedViewer.connectMouseSignals() self.referenceViewer.connectMouseSignals() - def update(self, ref, dupe): - self.resetState() + 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() @@ -46,36 +57,37 @@ class BaseController(QObject): self.referencePixmap = QPixmap(str(ref.path)) self.parent.buttonImgSwap.setEnabled(True) - self._updateImages() + self.updateBothImages(same_group) - def _updateImages(self): - target_size = None - if not self.selectedPixmap.isNull(): - target_size = self.selectedViewer.size() + def updateBothImages(self, same_group=False): + selected_size = self._updateImage( + self.selectedPixmap, self.scaledSelectedPixmap, self.selectedViewer, None, same_group) + # 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. + self._updateImage( + self.referencePixmap, self.scaledReferencePixmap, self.referenceViewer, selected_size, same_group) + + + def _updateImage(self, pixmap, scaledpixmap, viewer, target_size=None, same_group=False): + # If not same_group, we need full update""" + if not pixmap.isNull(): + target_size = viewer.size() if not self.bestFit: + if same_group: + viewer.setImage(pixmap) + viewer.centerViewAndUpdate() + return target_size # zoomed in state, expand - self.scaledSelectedPixmap = self.selectedPixmap.scaled( + scaledpixmap = pixmap.scaled( target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) else: # best fit, keep ratio always - self.scaledSelectedPixmap = self.selectedPixmap.scaled( + scaledpixmap = pixmap.scaled( target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.selectedViewer.setImage(self.scaledSelectedPixmap) - self.selectedViewer.centerViewAndUpdate() - - if not self.referencePixmap.isNull(): - # the selectedImage viewer widget sometimes ends up being bigger - # than the referenceImage viewer, which distorts by one pixel the - # scaled down pixmap for the reference, hence we'll reuse its size here. - # target_size = self.selectedViewer.size() - if not self.bestFit: - self.scaledReferencePixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) - else: - self.scaledReferencePixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - self.referenceViewer.setImage(self.scaledReferencePixmap) - self.referenceViewer.centerViewAndUpdate() + viewer.setImage(scaledpixmap) + viewer.centerViewAndUpdate() + return target_size @pyqtSlot(float) def scaleImagesBy(self, factor): @@ -84,8 +96,8 @@ class BaseController(QObject): self.selectedViewer.scaleBy(factor) self.referenceViewer.scaleBy(factor) - self.parent.buttonZoomIn.setEnabled(self.current_scale < 9.0) - self.parent.buttonZoomOut.setEnabled(self.current_scale > 0.5) + self.parent.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) + self.parent.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) self.parent.buttonBestFit.setEnabled(self.bestFit is False) self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) @@ -96,12 +108,13 @@ class BaseController(QObject): self.selectedViewer.scaleAt(scale) self.referenceViewer.scaleAt(scale) - self.parent.buttonZoomIn.setEnabled(self.current_scale < 9.0) - self.parent.buttonZoomOut.setEnabled(self.current_scale > 0.5) + self.parent.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) + self.parent.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) self.parent.buttonBestFit.setEnabled(self.bestFit is False) self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) def resetState(self): + """Only called when the group of dupes has changed""" self.selectedPixmap = QPixmap() self.scaledSelectedPixmap = QPixmap() self.referencePixmap = QPixmap() @@ -111,6 +124,9 @@ class BaseController(QObject): self.selectedViewer.resetCenter() self.referenceViewer.resetCenter() + self.selectedViewer.centerViewAndUpdate() + self.referenceViewer.centerViewAndUpdate() + self.parent.buttonZoomIn.setEnabled(False) self.parent.buttonZoomOut.setEnabled(False) self.parent.buttonBestFit.setEnabled(False) # active mode by default @@ -120,11 +136,11 @@ class BaseController(QObject): """No item from the model, disable and clear everything.""" self.resetState() self.selectedViewer.setImage(self.selectedPixmap) # null - self.selectedViewer.setDisabled(True) + self.selectedViewer.setEnabled(False) self.referenceViewer.setImage(self.referencePixmap) # null - self.referenceViewer.setDisabled(True) - self.parent.buttonImgSwap.setDisabled(True) - self.parent.buttonNormalSize.setDisabled(True) + self.referenceViewer.setEnabled(False) + self.parent.buttonImgSwap.setEnabled(False) + self.parent.buttonNormalSize.setEnabled(False) @pyqtSlot() def ScaleToBestFit(self): @@ -137,7 +153,9 @@ class BaseController(QObject): self.selectedViewer.resetCenter() self.referenceViewer.resetCenter() - self._updateImages() + + target_size = self._updateImage(self.selectedPixmap, self.scaledSelectedPixmap, self.selectedViewer, None, True) + self._updateImage(self.referencePixmap, self.scaledReferencePixmap, self.referenceViewer, target_size, True) self.parent.buttonBestFit.setEnabled(False) self.parent.buttonZoomOut.setEnabled(False) @@ -187,6 +205,11 @@ class QWidgetController(BaseController): self.selectedViewer.centerViewAndUpdate() self.referenceViewer.centerViewAndUpdate() + def _updateImage(self, pixmap, scaledpixmap, viewer, target_size, same_group): + #FIXME might not need this at all, REMOVE? + super()._updateImage(pixmap, scaledpixmap, viewer, target_size, same_group) + viewer.update() + class ScrollAreaController(BaseController): """Specialized version fro QLabel-based viewers.""" @@ -226,12 +249,12 @@ class ScrollAreaController(BaseController): def onMouseWheel(self, scale, delta): self.scaleImagesAt(scale) self.selectedViewer.adjustScrollBarsScaled(delta) - # Signal will automatically change the other: + # Signal from scrollbars will automatically change the other: # self.referenceViewer.adjustScrollBarsScaled(delta) @pyqtSlot(int) def onVScrollBarChanged(self, value): - if self.sender() is self.referenceViewer: + if self.sender() is self.referenceViewer._verticalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._verticalScrollBar.setValue(value) else: @@ -240,8 +263,8 @@ class ScrollAreaController(BaseController): @pyqtSlot(int) def onHScrollBarChanged(self, value): - if self.sender() is self.referenceViewer: - if not selectedViewer.ignore_signal: + 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: @@ -261,6 +284,7 @@ class ScrollAreaController(BaseController): self.selectedViewer.toggleScrollBars() self.referenceViewer.toggleScrollBars() + class GraphicsViewController(BaseController): """Specialized version fro QGraphicsView-based viewers.""" def __init__(self, selectedViewer, referenceViewer, parent): @@ -330,7 +354,7 @@ class QWidgetImageViewer(QWidget): """ Resets origin """ # Make sure we are not still panning around self._mousePanningDelta = QPointF() - self.scaleBy(1.0) + self.scaleAt(1.0) self.update() def changeEvent(self, event): @@ -385,8 +409,12 @@ class QWidgetImageViewer(QWidget): 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): @@ -516,11 +544,14 @@ class ScrollAreaImageViewer(QScrollArea): def getPixmap(self): return self._pixmap - def toggleScrollBars(self): + 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: @@ -592,26 +623,31 @@ class ScrollAreaImageViewer(QScrollArea): event.ignore() return oldScale = self._current_scale - if event.angleDelta().y() > 0: - self._current_scale *= 1.25 # zoom-in + if event.angleDelta().y() > 0: # zoom-in + if oldScale < MAX_SCALE: + self._current_scale *= 1.25 else: - self._current_scale *= 0.8 # zoom-out + 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, cache=True): - if pixmap.isNull(): - if not self._pixmap.isNull(): - self._pixmap = pixmap - self.update() - return - elif not self.isEnabled(): - self.setEnabled(True) - self.connectMouseSignals() + def setImage(self, pixmap): self._pixmap = pixmap self.label.setPixmap(pixmap) + self.label.update() self.label.adjustSize() + if pixmap.isNull(): + self.setEnabled(False) + self.disconnectMouseSignals() + else: + if not self.isEnabled(): + self.setEnabled(True) + self.connectMouseSignals() def centerViewAndUpdate(self): self._rect = self.label.rect() @@ -709,7 +745,7 @@ class ScrollAreaImageViewer(QScrollArea): self.verticalScrollBar().setValue( self.verticalScrollBar().value() - self._mousePanningDelta.y()) - def adjustScrollBarCentered(self, scrollBar, factor): + def adjustScrollBarCentered(self): """Just center in the middle.""" self._horizontalScrollBar.setValue( int(self._horizontalScrollBar.maximum() / 2)) @@ -722,7 +758,7 @@ class ScrollAreaImageViewer(QScrollArea): # self.label._mousePanningDelta = self._mousePanningDelta self._current_scale = 1.0 # self.scaleBy(1.0) - # self.label.update() # already called in scaleBy + # self.label.update() # already called in scaleBy² def setCenter(self, point): self._lastMouseClickPoint = point @@ -742,7 +778,7 @@ class ScrollAreaImageViewer(QScrollArea): self.scaleAt(1.0) self.ensureWidgetVisible(self.label) # needed for centering # self.label.update() - self.toggleScrollBars() + self.toggleScrollBars(True) @pyqtSlot(QPoint) def onDraggedMouse(self, delta): @@ -751,7 +787,7 @@ class ScrollAreaImageViewer(QScrollArea): # self.label.move(self.label.pos() + delta) # self.label.update() - #FIXME signal from scrollbars has already synced the values here! + # Signal from scrollbars had already synced the values here self.adjustScrollBarsAuto() # print(f"{self} onDraggedMouse slot with delta {delta}") From 9f15139d5fb70bd754bba8bd257f02c0dc1ed97d Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 24 Jun 2020 17:12:59 +0200 Subject: [PATCH 17/61] Fix view resetting when selecting reference only. * Needed to ignore the scrollbar changes in the disabled panel, sine a null pixmap would reset the bars to 0 and affect the selected viewer. * Keep view as same scale accross entries from the same group. --- qt/pe/details_dialog.py | 6 +- qt/pe/image_viewer.py | 185 +++++++++++++++++++++++----------------- 2 files changed, 109 insertions(+), 82 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 8d9ff198..e8c1f469 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -83,7 +83,7 @@ class DetailsDialog(DetailsDialogBase): # self.horizontalLayout.setColumnStretch(3,0) self.horizontalLayout.setSpacing(1) - self.selectedImageViewer = QWidgetImageViewer(self, "selectedImage") + self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -151,7 +151,7 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) - self.referenceImageViewer = QWidgetImageViewer(self, "referenceImage") + self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") # self.referenceImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -219,7 +219,7 @@ class DetailsDialog(DetailsDialogBase): def _update(self): if not self.app.model.selected_dupes: # No item from the model, disable and clear everything. - self.vController.clear_all() + self.vController.resetViewersState() return dupe = self.app.model.selected_dupes[0] group = self.app.model.results.get_group_of_duplicate(dupe) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 3c80b017..b6f12844 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -43,6 +43,7 @@ class BaseController(QObject): if group != self.cached_group: same_group = False self.resetState() + # self.current_scale = 1.0 self.cached_group = group @@ -51,70 +52,57 @@ class BaseController(QObject): self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() self.parent.buttonImgSwap.setEnabled(False) - # disable the blank widget. - self.referenceViewer.setImage(self.referencePixmap) + self.parent.buttonNormalSize.setEnabled(True) else: self.referencePixmap = QPixmap(str(ref.path)) self.parent.buttonImgSwap.setEnabled(True) + self.parent.buttonNormalSize.setEnabled(True) self.updateBothImages(same_group) + self.centerViews(same_group and self.referencePixmap.isNull()) def updateBothImages(self, same_group=False): + ignore_update = self.referencePixmap.isNull() + if ignore_update: + self.selectedViewer.ignore_signal = True selected_size = self._updateImage( - self.selectedPixmap, self.scaledSelectedPixmap, self.selectedViewer, None, same_group) + self.selectedPixmap, self.scaledSelectedPixmap, + self.selectedViewer, None, same_group) # 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. - self._updateImage( - self.referencePixmap, self.scaledReferencePixmap, self.referenceViewer, selected_size, 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): # If not same_group, we need full update""" - if not pixmap.isNull(): - target_size = viewer.size() - if not self.bestFit: - if same_group: - viewer.setImage(pixmap) - viewer.centerViewAndUpdate() - return target_size - # zoomed in state, expand - 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) - viewer.centerViewAndUpdate() + if pixmap.isNull(): + # 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 + 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 - @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.parent.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) - self.parent.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) - self.parent.buttonBestFit.setEnabled(self.bestFit is False) - self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) - - @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.parent.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) - self.parent.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) - self.parent.buttonBestFit.setEnabled(self.bestFit is False) - self.parent.buttonNormalSize.setEnabled(self.current_scale != 1.0) - def resetState(self): - """Only called when the group of dupes has changed""" + """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() @@ -123,24 +111,60 @@ class BaseController(QObject): self.current_scale = 1.0 self.selectedViewer.resetCenter() self.referenceViewer.resetCenter() + self.centerViews() - self.selectedViewer.centerViewAndUpdate() - self.referenceViewer.centerViewAndUpdate() - + #FIXME move buttons somwhere else self.parent.buttonZoomIn.setEnabled(False) self.parent.buttonZoomOut.setEnabled(False) self.parent.buttonBestFit.setEnabled(False) # active mode by default self.parent.buttonNormalSize.setEnabled(True) - def clear_all(self): + def resetViewersState(self): """No item from the model, disable and clear everything.""" - self.resetState() + # 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.resetCenter() + self.referenceViewer.resetCenter() + self.centerViews() + + #FIXME move buttons somwhere else + self.parent.buttonZoomIn.setEnabled(False) + self.parent.buttonZoomOut.setEnabled(False) + self.parent.buttonBestFit.setEnabled(False) # active mode by default + self.parent.buttonImgSwap.setEnabled(False) + self.parent.buttonNormalSize.setEnabled(False) + self.selectedViewer.setImage(self.selectedPixmap) # null self.selectedViewer.setEnabled(False) self.referenceViewer.setImage(self.referencePixmap) # null self.referenceViewer.setEnabled(False) - self.parent.buttonImgSwap.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(False) + + @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.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) + self.parent.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) + self.parent.buttonBestFit.setEnabled(self.bestFit is False) + self.parent.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0) @pyqtSlot() def ScaleToBestFit(self): @@ -156,6 +180,7 @@ class BaseController(QObject): 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.buttonBestFit.setEnabled(False) self.parent.buttonZoomOut.setEnabled(False) @@ -175,8 +200,7 @@ class BaseController(QObject): self.selectedViewer.setImage(self.selectedPixmap) self.referenceViewer.setImage(self.referencePixmap) - self.selectedViewer.centerViewAndUpdate() - self.referenceViewer.centerViewAndUpdate() + self.centerViews() self.selectedViewer.scaleToNormalSize() self.referenceViewer.scaleToNormalSize() @@ -186,6 +210,12 @@ class BaseController(QObject): self.parent.buttonZoomOut.setEnabled(True) self.parent.buttonBestFit.setEnabled(True) + def centerViews(self, only_selected=False): + self.selectedViewer.centerViewAndUpdate() + if only_selected: + return + self.referenceViewer.centerViewAndUpdate() + class QWidgetController(BaseController): """Specialized version for QWidget-based viewers.""" @@ -205,11 +235,6 @@ class QWidgetController(BaseController): self.selectedViewer.centerViewAndUpdate() self.referenceViewer.centerViewAndUpdate() - def _updateImage(self, pixmap, scaledpixmap, viewer, target_size, same_group): - #FIXME might not need this at all, REMOVE? - super()._updateImage(pixmap, scaledpixmap, viewer, target_size, same_group) - viewer.update() - class ScrollAreaController(BaseController): """Specialized version fro QLabel-based viewers.""" @@ -221,6 +246,15 @@ class ScrollAreaController(BaseController): 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 @@ -238,13 +272,6 @@ class ScrollAreaController(BaseController): self.referenceViewer.setCachedPixmap() self.selectedViewer.setCachedPixmap() - @pyqtSlot() - def syncCenters(self): - 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) @@ -285,6 +312,7 @@ class ScrollAreaController(BaseController): self.referenceViewer.toggleScrollBars() + class GraphicsViewController(BaseController): """Specialized version fro QGraphicsView-based viewers.""" def __init__(self, selectedViewer, referenceViewer, parent): @@ -366,7 +394,7 @@ class QWidgetImageViewer(QWidget): self.disconnectMouseSignals() def mousePressEvent(self, event): - if self.bestFit: + if self.bestFit or not self.isEnabled(): event.ignore() return if event.button() == Qt.LeftButton: @@ -382,7 +410,7 @@ class QWidgetImageViewer(QWidget): event.accept() def mouseMoveEvent(self, event): - if self.bestFit: + if self.bestFit or not self.isEnabled(): event.ignore() return @@ -394,7 +422,7 @@ class QWidgetImageViewer(QWidget): self.update() def mouseReleaseEvent(self, event): - if self.bestFit: + if self.bestFit or not self.isEnabled(): event.ignore() return if event.button() == Qt.LeftButton: @@ -404,7 +432,7 @@ class QWidgetImageViewer(QWidget): self.setMouseTracking(False) def wheelEvent(self, event): - if self.bestFit: + if self.bestFit or not self.isEnabled(): event.ignore() return @@ -421,8 +449,9 @@ class QWidgetImageViewer(QWidget): if pixmap.isNull(): if not self._pixmap.isNull(): self._pixmap = pixmap - self.disconnectMouseSignals() - self.update() + self.disconnectMouseSignals() + self.setEnabled(False) + self.update() return elif not self.isEnabled(): self.setEnabled(True) @@ -644,10 +673,9 @@ class ScrollAreaImageViewer(QScrollArea): if pixmap.isNull(): self.setEnabled(False) self.disconnectMouseSignals() - else: - if not self.isEnabled(): - self.setEnabled(True) - self.connectMouseSignals() + elif not self.isEnabled(): + self.setEnabled(True) + self.connectMouseSignals() def centerViewAndUpdate(self): self._rect = self.label.rect() @@ -755,10 +783,9 @@ class ScrollAreaImageViewer(QScrollArea): def resetCenter(self): """ Resets origin """ self._mousePanningDelta = QPoint() - # self.label._mousePanningDelta = self._mousePanningDelta self._current_scale = 1.0 # self.scaleBy(1.0) - # self.label.update() # already called in scaleBy² + # self.label.update() # already called in scaleBy def setCenter(self, point): self._lastMouseClickPoint = point From 370b582c9ba1bb6b3ab632f3fe868632bbbae6a5 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sun, 28 Jun 2020 03:31:03 +0200 Subject: [PATCH 18/61] Add working zoom functions to GraphicsView viewers. --- qt/pe/details_dialog.py | 20 +- qt/pe/image_viewer.py | 467 +++++++++++++++++++++++++++++++--------- 2 files changed, 376 insertions(+), 111 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index e8c1f469..230e0abc 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -83,7 +83,7 @@ class DetailsDialog(DetailsDialogBase): # self.horizontalLayout.setColumnStretch(3,0) self.horizontalLayout.setSpacing(1) - self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") + self.selectedImageViewer = GraphicsViewViewer(self, "selectedImage") # self.selectedImage = QLabel(self) # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) # sizePolicy.setHorizontalStretch(0) @@ -100,6 +100,11 @@ class DetailsDialog(DetailsDialogBase): # self.horizontalLayout.addItem(QSpacerItem(5,0, QSizePolicy.Minimum), # 1, 3, 1, 1, Qt.Alignment(Qt.AlignRight)) + # FIXME make a subclass to initialize buttons later + # FIXME use qwidgetaction to make the popup on resize work -> QWidgetAction::createWidget() + # FIXME figure out why margins are changing when the window is updating (after Normal Size, on resize) + # it seems toggling the scrollbars reduce viewport size and messes up sizeHint returned? + # thus shrinking the space available for the toolbar? self.verticalToolBar = QToolBar(self) # self.verticalToolBar.setMaximumWidth(10) self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) @@ -151,11 +156,12 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) - self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") + self.referenceImageViewer = GraphicsViewViewer(self, "referenceImage") # self.referenceImage = QLabel(self) - # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) + # sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) # sizePolicy.setHorizontalStretch(0) # sizePolicy.setVerticalStretch(0) + # self.verticalToolBar.setSizePolicy(sizePolicy) # sizePolicy.setHeightForWidth( # self.referenceImage.sizePolicy().hasHeightForWidth() # ) @@ -217,6 +223,8 @@ class DetailsDialog(DetailsDialogBase): self) def _update(self): + if self.vController is None: # Not yet constructed! + return if not self.app.model.selected_dupes: # No item from the model, disable and clear everything. self.vController.resetViewersState() @@ -225,8 +233,6 @@ class DetailsDialog(DetailsDialogBase): group = self.app.model.results.get_group_of_duplicate(dupe) ref = group.ref - if self.vController is None: # Not yet constructed! - return self.vController.updateView(ref, dupe, group) # --- Override @@ -277,11 +283,11 @@ class DetailsDialog(DetailsDialogBase): @pyqtSlot() def zoomIn(self): - self.vController.scaleImagesBy(1.25) + self.vController.zoomIn() @pyqtSlot() def zoomOut(self): - self.vController.scaleImagesBy(0.8) + self.vController.zoomOut() @pyqtSlot() def zoomBestFit(self): diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index b6f12844..f5b8d961 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -3,7 +3,7 @@ # 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 +from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor, QTransform from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, QScrollBar, QApplication, QAbstractScrollArea ) @@ -62,6 +62,7 @@ class BaseController(QObject): self.centerViews(same_group and self.referencePixmap.isNull()) def updateBothImages(self, same_group=False): + # FIXME this is called on every resize event, ignore_update = self.referencePixmap.isNull() if ignore_update: self.selectedViewer.ignore_signal = True @@ -79,7 +80,7 @@ class BaseController(QObject): self.selectedViewer.ignore_signal = False def _updateImage(self, pixmap, scaledpixmap, viewer, target_size=None, same_group=False): - # If not same_group, we need full update""" + # FIXME this is called on every resize event, split into a separate function if pixmap.isNull(): # disable the blank widget. viewer.setImage(pixmap) @@ -91,6 +92,7 @@ class BaseController(QObject): 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: @@ -109,8 +111,15 @@ class BaseController(QObject): 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() #FIXME move buttons somwhere else @@ -128,8 +137,12 @@ class BaseController(QObject): 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() #FIXME move buttons somwhere else @@ -144,6 +157,14 @@ class BaseController(QObject): 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.""" @@ -171,6 +192,8 @@ class BaseController(QObject): """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) @@ -301,7 +324,7 @@ class ScrollAreaController(BaseController): def scaleImagesBy(self, factor): super().scaleImagesBy(factor) # The other is automatically updated via sigals - self.selectedViewer.adjustScrollBarsFactor(factor) + # self.selectedViewer.adjustScrollBarsFactor(factor) @pyqtSlot() def ScaleToBestFit(self): @@ -318,6 +341,11 @@ class GraphicsViewController(BaseController): def __init__(self, selectedViewer, referenceViewer, parent): super().__init__(selectedViewer, referenceViewer, parent) + def _setupConnections(self): + super()._setupConnections() + self.selectedViewer.connectScrollBars() + self.referenceViewer.connectScrollBars() + @pyqtSlot() def syncCenters(self): if self.sender() is self.referenceViewer: @@ -325,6 +353,179 @@ class GraphicsViewController(BaseController): else: self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + @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(self.referenceViewer.getCenter()) + else: + self.referenceViewer.scaleBy(factor) + self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + + # 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() + def swapPixmaps(self): + self.referenceViewer._pixmap.swap(self.selectedViewer._pixmap) + self.referenceViewer.setCachedPixmap() + self.selectedViewer.setCachedPixmap() + + @pyqtSlot() + def ScaleToBestFit(self): + """Setup before scaling to bestfit""" + self.setBestFit(True) + self.current_scale = 1.0 + + self.selectedViewer.fitScale() + self.referenceViewer.fitScale() + + self.parent.buttonBestFit.setEnabled(False) + self.parent.buttonZoomOut.setEnabled(False) + self.parent.buttonZoomIn.setEnabled(False) + self.parent.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.buttonImgSwap.setEnabled(False) + self.parent.buttonNormalSize.setEnabled(True) + else: + self.referencePixmap = QPixmap(str(ref.path)) + self.parent.buttonImgSwap.setEnabled(True) + self.parent.buttonNormalSize.setEnabled(True) + + self.selectedViewer.setImage(self.selectedPixmap) + self.referenceViewer.setImage(self.referencePixmap) + self.updateBothImages(same_group) + self.centerViews(same_group and self.referencePixmap.isNull()) + + 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() + + #FIXME move buttons somwhere else + self.parent.buttonZoomIn.setEnabled(False) + self.parent.buttonZoomOut.setEnabled(False) + self.parent.buttonBestFit.setEnabled(False) # active mode by default + self.parent.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() + + #FIXME move buttons somwhere else + self.parent.buttonZoomIn.setEnabled(False) + self.parent.buttonZoomOut.setEnabled(False) + self.parent.buttonBestFit.setEnabled(False) # active mode by default + self.parent.buttonImgSwap.setEnabled(False) + self.parent.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.setNewCenter(self.selectedViewer._scene.sceneRect().center()) + # self.selectedViewer._centerPoint = self.selectedViewer.viewport().rect().center() + + + # self.referenceViewer._mousePanningDelta = self.selectedViewer._mousePanningDelta + # # self.selectedViewer._mousePanningDelta = self.referenceViewer._mousePanningDelta + # self.selectedViewer.adjustScrollBarsAuto() + # self.referenceViewer.adjustScrollBarsAuto() + + self.selectedViewer.centerOn(self.selectedViewer._centerPoint) + + # self.selectedViewer.updateCenterPoint() + # self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + # self.selectedViewer.setCenter(self.referenceViewer.getCenter()) + # self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + # The other is automatically updated via sigals + # self.selectedViewer.adjustScrollBarsFactor(factor) + + + class QWidgetImageViewer(QWidget): """Use a QPixmap, but no scrollbars.""" @@ -340,7 +541,7 @@ class QWidgetImageViewer(QWidget): self._rect = QRectF() self._lastMouseClickPoint = QPointF() self._mousePanningDelta = QPointF() - self._current_scale = 1.0 + self.current_scale = 1.0 self._drag = False self._dragConnection = None self._wheelConnection = None @@ -374,7 +575,7 @@ class QWidgetImageViewer(QWidget): def paintEvent(self, event): painter = QPainter(self) painter.translate(self.rect().center()) - painter.scale(self._current_scale, self._current_scale) + painter.scale(self.current_scale, self.current_scale) painter.translate(self._mousePanningDelta) painter.drawPixmap(self._rect.topLeft(), self._pixmap) @@ -382,7 +583,6 @@ class QWidgetImageViewer(QWidget): """ Resets origin """ # Make sure we are not still panning around self._mousePanningDelta = QPointF() - self.scaleAt(1.0) self.update() def changeEvent(self, event): @@ -415,7 +615,7 @@ class QWidgetImageViewer(QWidget): return self._mousePanningDelta += (event.pos() - self._lastMouseClickPoint) \ - * 1.0 / self._current_scale + * 1.0 / self.current_scale self._lastMouseClickPoint = event.pos() if self._drag: self.mouseDragged.emit(self._mousePanningDelta) @@ -437,11 +637,11 @@ class QWidgetImageViewer(QWidget): return if event.angleDelta().y() > 0: - if self._current_scale > MAX_SCALE: + if self.current_scale > MAX_SCALE: return self.mouseWheeled.emit(1.25) # zoom-in else: - if self._current_scale < MIN_SCALE: + if self.current_scale < MIN_SCALE: return self.mouseWheeled.emit(0.8) # zoom-out @@ -467,11 +667,11 @@ class QWidgetImageViewer(QWidget): return True if not self.pixmap.isNull() else False def scaleBy(self, factor): - self._current_scale *= factor + self.current_scale *= factor self.update() def scaleAt(self, scale): - self._current_scale = scale + self.current_scale = scale self.update() def sizeHint(self): @@ -480,7 +680,7 @@ class QWidgetImageViewer(QWidget): @pyqtSlot() def scaleToNormalSize(self): """Called when the pixmap is set back to original size.""" - self._current_scale = 1.0 + self.current_scale = 1.0 self.update() @pyqtSlot(QPointF) @@ -495,23 +695,23 @@ class ScalablePixmap(QWidget): def __init__(self, parent): super().__init__(parent) self._pixmap = QPixmap() - self._current_scale = 1.0 + self.current_scale = 1.0 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.scale(self.current_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}") + # print(f"ScalableWidget paintEvent scale {self.current_scale}") def setPixmap(self, pixmap): self._pixmap = pixmap # self.update() def sizeHint(self): - return self._pixmap.size() * self._current_scale + return self._pixmap.size() * self.current_scale # return self._pixmap.size() def minimumSizeHint(self): @@ -535,7 +735,7 @@ class ScrollAreaImageViewer(QScrollArea): self._rect = QRectF() self._lastMouseClickPoint = QPointF() self._mousePanningDelta = QPoint() - self._current_scale = 1.0 + self.current_scale = 1.0 self._drag = False self._dragConnection = None self._wheelConnection = None @@ -606,6 +806,7 @@ class ScrollAreaImageViewer(QScrollArea): def connectScrollBars(self): """Only call once controller is connected.""" # Cyclic connections are handled by Qt + return self._verticalScrollBar.valueChanged.connect( self.controller.onVScrollBarChanged, Qt.UniqueConnection) self._horizontalScrollBar.valueChanged.connect( @@ -651,19 +852,19 @@ class ScrollAreaImageViewer(QScrollArea): if self.bestFit: event.ignore() return - oldScale = self._current_scale + oldScale = self.current_scale if event.angleDelta().y() > 0: # zoom-in if oldScale < MAX_SCALE: - self._current_scale *= 1.25 + self.current_scale *= 1.25 else: if oldScale > MIN_SCALE: # zoom-out - self._current_scale *= 0.8 - if oldScale == self._current_scale: + 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) + delta = (deltaToPos * self.current_scale) - (deltaToPos * oldScale) + self.mouseWheeled.emit(self.current_scale, delta) def setImage(self, pixmap): self._pixmap = pixmap @@ -680,7 +881,7 @@ class ScrollAreaImageViewer(QScrollArea): def centerViewAndUpdate(self): self._rect = self.label.rect() self.label.rect().translate(-self._rect.center()) - self.label._current_scale = self._current_scale + self.label.current_scale = self.current_scale self.label.update() # self.viewport().update() @@ -693,8 +894,8 @@ class ScrollAreaImageViewer(QScrollArea): return True if not self.pixmap.isNull() else False def scaleBy(self, factor): - self._current_scale *= factor - # print(f"scaleBy(factor={factor}) 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( @@ -713,7 +914,7 @@ class ScrollAreaImageViewer(QScrollArea): self.label.resize(self.label.size().__imul__(factor)) # self.label.updateGeometry() - self.label._current_scale = self._current_scale + 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() @@ -732,9 +933,9 @@ class ScrollAreaImageViewer(QScrollArea): # self.adjustScrollBarCentered() def scaleAt(self, scale): - self._current_scale = scale + self.current_scale = scale self.label.resize(self._pixmap.size().__imul__(scale)) - self.label._current_scale = scale + self.label.current_scale = scale self.label.update() # self.label.adjustSize() @@ -783,7 +984,7 @@ class ScrollAreaImageViewer(QScrollArea): def resetCenter(self): """ Resets origin """ self._mousePanningDelta = QPoint() - self._current_scale = 1.0 + self.current_scale = 1.0 # self.scaleBy(1.0) # self.label.update() # already called in scaleBy @@ -794,10 +995,10 @@ class ScrollAreaImageViewer(QScrollArea): return self._lastMouseClickPoint def sizeHint(self): - return self._pixmap.rect().size() + return self.viewport().rect().size() - def viewportSizeHint(self): - return self._pixmap.rect().size() + # def viewportSizeHint(self): + # return self.viewport().rect().size() @pyqtSlot() def scaleToNormalSize(self): @@ -827,9 +1028,9 @@ class ScrollAreaImageViewer(QScrollArea): from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsPixmapItem class GraphicsViewViewer(QGraphicsView): - """Re-Implementation.""" + """Re-Implementation using a more full fledged class.""" mouseDragged = pyqtSignal() - mouseWheeled = pyqtSignal(bool) + mouseWheeled = pyqtSignal(float, QPointF) def __init__(self, parent, name=""): super().__init__(parent) @@ -841,7 +1042,9 @@ class GraphicsViewViewer(QGraphicsView): self._lastMouseClickPoint = QPointF() self._mousePanningDelta = QPointF() self._scaleFactor = 1.3 - self._current_scale = 1.0 + self.zoomInFactor = self._scaleFactor + self.zoomOutFactor = 1.0 / self._scaleFactor + self.current_scale = 1.0 self._drag = False self._dragConnection = None self._wheelConnection = None @@ -849,25 +1052,29 @@ class GraphicsViewViewer(QGraphicsView): self.wantScrollBars = True self.bestFit = True self.controller = None - self._centerPoint = QPointF(0.0, 0.0) + self._centerPoint = QPointF() self.centerOn(self._centerPoint) # 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.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.toggleScrollBars() else: self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setResizeAnchor(QGraphicsView.AnchorViewCenter) - self.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + self.setAlignment(Qt.AlignCenter) self.setViewportUpdateMode (QGraphicsView.FullViewportUpdate) self.setMouseTracking(True) @@ -877,7 +1084,7 @@ class GraphicsViewViewer(QGraphicsView): self.controller.syncCenters) if not self._wheelConnection: self._wheelConnection = self.mouseWheeled.connect( - self.controller.scaleImages) + self.controller.onMouseWheel) def disconnectMouseSignals(self): if self._dragConnection: @@ -887,6 +1094,27 @@ class GraphicsViewViewer(QGraphicsView): 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() @@ -897,7 +1125,6 @@ class GraphicsViewViewer(QGraphicsView): self._drag = False event.ignore() return - self._lastMouseClickPoint = event.pos() self._app.setOverrideCursor(Qt.ClosedHandCursor) self.setMouseTracking(True) @@ -905,40 +1132,53 @@ class GraphicsViewViewer(QGraphicsView): super().mousePressEvent(event) # event.accept() - def mouseMoveEvent(self, event): - if self.bestFit: - event.ignore() - return - - self._centerPoint = self.mapToScene( self.rect().center() ) - super().mouseMoveEvent(event) - - if self._drag: - self.mouseDragged.emit() - # self._item.update() - - 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 wheelEvent(self, event): + def mouseMoveEvent(self, event): if self.bestFit: event.ignore() return + if self._drag: + delta = (event.pos() - self._lastMouseClickPoint) + 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.viewport().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: - self.mouseWheeled.emit(True) # zoom-in + factor = self.zoomInFactor else: - self.mouseWheeled.emit(False) # zoom-out + factor = self.zoomOutFactor + 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) def setImage(self, pixmap): self._pixmap = pixmap @@ -947,14 +1187,15 @@ 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(QRectF(self._pixmap.rect())) # not sure if this works def centerViewAndUpdate(self): + # self._rect = self.sceneRect() + # self._rect.translate(-self._rect.center()) + # self._item.update() + # self.viewport().update() pass - def scaleBy(self, factor): - # super().scale(factor, factor) - self.zoom(factor) def setCenter(self, point): self._centerPoint = point @@ -966,59 +1207,77 @@ class GraphicsViewViewer(QGraphicsView): def resetCenter(self): """ Resets origin """ self._mousePanningDelta = QPointF() - self._current_scale = 1.0 - self.scaleBy(1.0) + self.current_scale = 1.0 # self.update() - self.setCenter(self._scene.sceneRect().center()) + # self.setCenter(self._scene.sceneRect().center()) 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() ) + # self.scaleChanged.emit( self.transform().m22() ) + + 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.scaleBy(1.0) # FIXME - - super().fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio ) - self.setNewCenter(self._scene.sceneRect().center()) + self.bestFit = False + self.scaleAt(1.0) + self.toggleScrollBars() self.update() - # def sizeHint(self): - # return self._item.rect().size() + 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 viewportSizeHint(self): - # return self._item.rect().size() + # return self.viewport().rect().size() - def zoom_in(self): - self.zoom(self._scaleFactor) - - def zoom_out(self): - self.zoom(1.0 / self._scaleFactor) - - def zoom(self, factor): - #Get the position of the mouse before scaling, in scene coords - pointBeforeScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos()))) - - #Get the original screen centerpoint - screenCenter = self.mapToScene( self.rect().center() ) - - super().scale(factor, factor) - - #Get the position after scaling, in scene coords - pointAfterScale = QPointF( self.mapToScene( self.mapFromGlobal(QCursor.pos()) ) ) - - #Get the offset of how the screen moved - offset = QPointF( pointBeforeScale - pointAfterScale) - - #Adjust to the new center for correct zooming - newCenter = QPointF(screenCenter + offset) - self.setNewCenter(newCenter) - - # self.updateSceneRect(self._item.rect()) # TEST THIS? - - # mouse position has changed!! - # emit mouseMoved( QGraphicsView::mapToScene( event->pos() ) ); - # emit mouseMoved( QGraphicsView::mapToScene( mapFromGlobal(QCursor::pos()) ) ); - # emit somethingChanged(); + 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 adjustScrollBarsAuto(self): + """After panning, update accordingly.""" + self.horizontalScrollBar().setValue( + self.horizontalScrollBar().value() - self._mousePanningDelta.x()) + self.verticalScrollBar().setValue( + self.verticalScrollBar().value() - self._mousePanningDelta.y()) \ No newline at end of file From 36ab84423a90a5d3ed6b530f0a06c5c7d40898be Mon Sep 17 00:00:00 2001 From: glubsy Date: Sun, 28 Jun 2020 19:51:57 +0200 Subject: [PATCH 19/61] Move buttons into the toolbar class. * Moved the QToolbar into the image viewer's translation unit. * QAction are still attached to the dialog window for shortcuts to work --- qt/pe/details_dialog.py | 154 ++++-------------------- qt/pe/image_viewer.py | 257 +++++++++++++++++++++++++++++----------- 2 files changed, 205 insertions(+), 206 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 230e0abc..c653713a 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -5,10 +5,8 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal -from PyQt5.QtGui import QPixmap, QIcon, QKeySequence from PyQt5.QtWidgets import (QLayout, QVBoxLayout, QAbstractItemView, QHBoxLayout, - QLabel, QSizePolicy, QToolBar, QToolButton, QGridLayout, QStyle, QAction, - QWidget, QApplication, QSpacerItem, QSplitter, QFrame ) + QSizePolicy, QGridLayout, QWidget, QSpacerItem, QSplitter, QFrame ) from hscommon.trans import trget from hscommon import desktop @@ -16,7 +14,7 @@ from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from qtlib.util import createActions from qt.pe.image_viewer import ( - QWidgetImageViewer, ScrollAreaImageViewer, GraphicsViewViewer, + ViewerToolBar, QWidgetImageViewer, ScrollAreaImageViewer, GraphicsViewViewer, QWidgetController, ScrollAreaController, GraphicsViewController) tr = trget("ui") @@ -25,42 +23,7 @@ class DetailsDialog(DetailsDialogBase): self.vController = None super().__init__(parent, app) - def setupActions(self): - # (name, shortcut, icon, desc, func) - ACTIONS = [ - ( - "actionZoomIn", - QKeySequence.ZoomIn, - "zoom-in", - tr("Increase zoom"), - self.zoomIn, - ), - ( - "actionZoomOut", - QKeySequence.ZoomOut, - "zoom-out", - tr("Decrease zoom"), - self.zoomOut, - ), - ( - "actionNormalSize", - QKeySequence.Refresh, - "zoom-original", - tr("Normal size"), - self.zoomNormalSize, - ), - ( - "actionBestFit", - tr("Ctrl+p"), - "zoom-best-fit", - tr("Best fit"), - self.zoomBestFit, - ) - ] - createActions(ACTIONS, self) - def _setupUi(self): - self.setupActions() self.setWindowTitle(tr("Details")) self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) @@ -80,7 +43,12 @@ class DetailsDialog(DetailsDialogBase): self.horizontalLayout.setColumnStretch(0,24) self.horizontalLayout.setColumnStretch(1,1) self.horizontalLayout.setColumnStretch(2,24) - # self.horizontalLayout.setColumnStretch(3,0) + + # This avoids toolbar getting incorrectly resized when window resizes + self.horizontalLayout.setRowStretch(0,1) + self.horizontalLayout.setRowStretch(1,24) + self.horizontalLayout.setRowStretch(2,1) + self.horizontalLayout.setSpacing(1) self.selectedImageViewer = GraphicsViewViewer(self, "selectedImage") @@ -97,63 +65,26 @@ class DetailsDialog(DetailsDialogBase): # # self.horizontalLayout.addWidget(self.selectedImage) self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1) + # We use different types of controller depending on the + # underlying widgets we use to display images + # because their interface and methods might differ + if isinstance(self.selectedImageViewer, QWidgetImageViewer): + self.vController = QWidgetController(self) + elif isinstance(self.selectedImageViewer, ScrollAreaImageViewer): + self.vController = ScrollAreaController(self) + elif isinstance(self.selectedImageViewer, GraphicsViewViewer): + self.vController = GraphicsViewController(self) + # self.horizontalLayout.addItem(QSpacerItem(5,0, QSizePolicy.Minimum), # 1, 3, 1, 1, Qt.Alignment(Qt.AlignRight)) - # FIXME make a subclass to initialize buttons later - # FIXME use qwidgetaction to make the popup on resize work -> QWidgetAction::createWidget() - # FIXME figure out why margins are changing when the window is updating (after Normal Size, on resize) - # it seems toggling the scrollbars reduce viewport size and messes up sizeHint returned? - # thus shrinking the space available for the toolbar? - self.verticalToolBar = QToolBar(self) + self.verticalToolBar = ViewerToolBar(self, self.vController) # self.verticalToolBar.setMaximumWidth(10) self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) # self.subVLayout = QVBoxLayout(self) # self.subVLayout.addWidget(self.verticalToolBar) # self.horizontalLayout.addLayout(self.subVLayout) - self.buttonImgSwap = QToolButton(self.verticalToolBar) - 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.swapImages) - self.buttonImgSwap.released.connect(self.swapImages) - - self.buttonZoomIn = QToolButton(self.verticalToolBar) - self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.buttonZoomIn.setDefaultAction(self.actionZoomIn) - self.buttonZoomIn.setText('ZoomIn') - self.buttonZoomIn.setIcon(QIcon.fromTheme('zoom-in')) - - self.buttonZoomOut = QToolButton(self.verticalToolBar) - 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.verticalToolBar) - 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.verticalToolBar) - 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.verticalToolBar.addWidget(self.buttonImgSwap) - self.verticalToolBar.addWidget(self.buttonZoomIn) - self.verticalToolBar.addWidget(self.buttonZoomOut) - self.verticalToolBar.addWidget(self.buttonNormalSize) - self.verticalToolBar.addWidget(self.buttonBestFit) - self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) self.referenceImageViewer = GraphicsViewViewer(self, "referenceImage") @@ -197,30 +128,7 @@ class DetailsDialog(DetailsDialogBase): # self.tableView.hide() - self.buttonImgSwap.setEnabled(False) - self.buttonZoomIn.setEnabled(False) - self.buttonZoomOut.setEnabled(False) - self.buttonNormalSize.setEnabled(False) - self.buttonBestFit.setEnabled(False) - - # We use different types of controller depending on the - # underlying widgets we use to display images - # because their interface and methods might differ - if isinstance(self.selectedImageViewer, QWidgetImageViewer): - self.vController = QWidgetController( - self.selectedImageViewer, - self.referenceImageViewer, - self) - elif isinstance(self.selectedImageViewer, ScrollAreaImageViewer): - self.vController = ScrollAreaController( - self.selectedImageViewer, - self.referenceImageViewer, - self) - elif isinstance(self.selectedImageViewer, GraphicsViewViewer): - self.vController = GraphicsViewController( - self.selectedImageViewer, - self.referenceImageViewer, - self) + self.vController.setupViewers(self.selectedImageViewer, self.referenceImageViewer) def _update(self): if self.vController is None: # Not yet constructed! @@ -274,25 +182,3 @@ class DetailsDialog(DetailsDialogBase): if self.isVisible(): self._update() - # ImageViewers - @pyqtSlot() - def swapImages(self): - self.vController.swapPixmaps() - # swap the columns in the details table as well - self.tableView.horizontalHeader().swapSections(1, 2) - - @pyqtSlot() - def zoomIn(self): - self.vController.zoomIn() - - @pyqtSlot() - def zoomOut(self): - self.vController.zoomOut() - - @pyqtSlot() - def zoomBestFit(self): - self.vController.ScaleToBestFit() - - @pyqtSlot() - def zoomNormalSize(self): - self.vController.zoomNormalSize() diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index f5b8d961..c7783c5d 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -3,25 +3,130 @@ # 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, QTransform -from PyQt5.QtWidgets import ( QLabel, QSizePolicy, QWidget, QScrollArea, - QScrollBar, QApplication, QAbstractScrollArea ) +from PyQt5.QtGui import QPixmap, QPainter, QPalette, QCursor, QTransform, QIcon, QKeySequence +from PyQt5.QtWidgets import ( QToolBar, QToolButton, QAction, QLabel, QSizePolicy, QWidget, QScrollArea, + QScrollBar, QApplication, QAbstractScrollArea, QStyle) +from hscommon.trans import trget +tr = trget("ui") MAX_SCALE = 12.0 MIN_SCALE = 0.1 + +class ViewerToolBar(QToolBar): + def __init__(self, parent, controller): + super().__init__() + 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 createActions(self, actions, target): + # TODO try with QWidgetAction() instead in order to have + # the popup menu work in the toolbar (if resized below minimum height) + # actions = [(name, shortcut, icon, desc, func)] + for name, shortcut, icon, desc, func in actions: + action = QAction(target) + if icon: + action.setIcon(QIcon(QPixmap(":/" + icon))) + if shortcut: + action.setShortcut(shortcut) + action.setText(desc) + action.triggered.connect(func) + setattr(target, name, action) + + def setupActions(self, controller): + ACTIONS = [ + ( + "actionZoomIn", + QKeySequence.ZoomIn, + "zoom-in", + tr("Increase zoom"), + controller.zoomIn, + ), + ( + "actionZoomOut", + QKeySequence.ZoomOut, + "zoom-out", + tr("Decrease zoom"), + controller.zoomOut, + ), + ( + "actionNormalSize", + QKeySequence.Refresh, + "zoom-original", + tr("Normal size"), + controller.zoomNormalSize, + ), + ( + "actionBestFit", + tr("Ctrl+p"), + "zoom-best-fit", + tr("Best fit"), + controller.zoomBestFit, + ) + ] + self.createActions(ACTIONS, self.parent) + + + 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.parent.actionZoomIn) + self.buttonZoomIn.setText('ZoomIn') + self.buttonZoomIn.setIcon(QIcon.fromTheme('zoom-in')) + + self.buttonZoomOut = QToolButton(self) + self.buttonZoomOut.setToolButtonStyle(Qt.ToolButtonIconOnly) + self.buttonZoomOut.setDefaultAction(self.parent.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.parent.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.parent.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, selectedViewer, referenceViewer, parent): + def __init__(self, parent): super().__init__() - self.selectedViewer = selectedViewer - self.referenceViewer = referenceViewer - self.selectedViewer.controller = self - self.referenceViewer.controller = self - self._setupConnections() + self.selectedViewer = None + self.referenceViewer = None # cached pixmaps self.selectedPixmap = QPixmap() self.referencePixmap = QPixmap() @@ -33,6 +138,13 @@ class BaseController(QObject): 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() @@ -51,12 +163,12 @@ class BaseController(QObject): if ref is dupe: # currently selected file is the actual reference file self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() - self.parent.buttonImgSwap.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(True) + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) else: self.referencePixmap = QPixmap(str(ref.path)) - self.parent.buttonImgSwap.setEnabled(True) - self.parent.buttonNormalSize.setEnabled(True) + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) self.updateBothImages(same_group) self.centerViews(same_group and self.referencePixmap.isNull()) @@ -123,10 +235,10 @@ class BaseController(QObject): self.centerViews() #FIXME move buttons somwhere else - self.parent.buttonZoomIn.setEnabled(False) - self.parent.buttonZoomOut.setEnabled(False) - self.parent.buttonBestFit.setEnabled(False) # active mode by default - self.parent.buttonNormalSize.setEnabled(True) + self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) + self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) def resetViewersState(self): """No item from the model, disable and clear everything.""" @@ -146,11 +258,11 @@ class BaseController(QObject): self.centerViews() #FIXME move buttons somwhere else - self.parent.buttonZoomIn.setEnabled(False) - self.parent.buttonZoomOut.setEnabled(False) - self.parent.buttonBestFit.setEnabled(False) # active mode by default - self.parent.buttonImgSwap.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(False) + self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) + self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) self.selectedViewer.setImage(self.selectedPixmap) # null self.selectedViewer.setEnabled(False) @@ -182,13 +294,13 @@ class BaseController(QObject): self.updateButtons() def updateButtons(self): - self.parent.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) - self.parent.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) - self.parent.buttonBestFit.setEnabled(self.bestFit is False) - self.parent.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0) + self.parent.verticalToolBar.buttonZoomIn.setEnabled(self.current_scale < MAX_SCALE) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(self.current_scale > MIN_SCALE) + self.parent.verticalToolBar.buttonBestFit.setEnabled(self.bestFit is False) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0) @pyqtSlot() - def ScaleToBestFit(self): + def zoomBestFit(self): """Setup before scaling to bestfit""" self.setBestFit(True) self.current_scale = 1.0 @@ -205,10 +317,10 @@ class BaseController(QObject): self._updateImage(self.referencePixmap, self.scaledReferencePixmap, self.referenceViewer, target_size, True) self.centerViews() - self.parent.buttonBestFit.setEnabled(False) - self.parent.buttonZoomOut.setEnabled(False) - self.parent.buttonZoomIn.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(True) + 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 setBestFit(self, value): self.bestFit = value @@ -228,10 +340,10 @@ class BaseController(QObject): 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) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) + self.parent.verticalToolBar.buttonZoomIn.setEnabled(True) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(True) + self.parent.verticalToolBar.buttonBestFit.setEnabled(True) def centerViews(self, only_selected=False): self.selectedViewer.centerViewAndUpdate() @@ -239,11 +351,16 @@ class BaseController(QObject): 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, selectedViewer, referenceViewer, parent): - super().__init__(selectedViewer, referenceViewer, parent) + def __init__(self, parent): + super().__init__(parent) @pyqtSlot(QPointF) def onDraggedMouse(self, delta): @@ -253,16 +370,17 @@ class QWidgetController(BaseController): self.referenceViewer.onDraggedMouse(delta) @pyqtSlot() - def swapPixmaps(self): + def swapImages(self): self.selectedViewer.getPixmap().swap(self.referenceViewer.getPixmap()) self.selectedViewer.centerViewAndUpdate() self.referenceViewer.centerViewAndUpdate() + super().swapImages() class ScrollAreaController(BaseController): """Specialized version fro QLabel-based viewers.""" - def __init__(self, selectedViewer, referenceViewer, parent): - super().__init__(selectedViewer, referenceViewer, parent) + def __init__(self, parent): + super().__init__(parent) def _setupConnections(self): super()._setupConnections() @@ -290,10 +408,11 @@ class ScrollAreaController(BaseController): self.referenceViewer.ignore_signal = False @pyqtSlot() - def swapPixmaps(self): + 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): @@ -327,9 +446,9 @@ class ScrollAreaController(BaseController): # self.selectedViewer.adjustScrollBarsFactor(factor) @pyqtSlot() - def ScaleToBestFit(self): + def zoomBestFit(self): # Disable scrollbars to avoid GridLayout size rounding "error" - super().ScaleToBestFit() + super().zoomBestFit() print("toggling scrollbars") self.selectedViewer.toggleScrollBars() self.referenceViewer.toggleScrollBars() @@ -338,8 +457,8 @@ class ScrollAreaController(BaseController): class GraphicsViewController(BaseController): """Specialized version fro QGraphicsView-based viewers.""" - def __init__(self, selectedViewer, referenceViewer, parent): - super().__init__(selectedViewer, referenceViewer, parent) + def __init__(self, parent): + super().__init__(parent) def _setupConnections(self): super()._setupConnections() @@ -386,13 +505,14 @@ class GraphicsViewController(BaseController): self.referenceViewer._horizontalScrollBar.setValue(value) @pyqtSlot() - def swapPixmaps(self): + def swapImages(self): self.referenceViewer._pixmap.swap(self.selectedViewer._pixmap) self.referenceViewer.setCachedPixmap() self.selectedViewer.setCachedPixmap() + super().swapImages() @pyqtSlot() - def ScaleToBestFit(self): + def zoomBestFit(self): """Setup before scaling to bestfit""" self.setBestFit(True) self.current_scale = 1.0 @@ -400,10 +520,10 @@ class GraphicsViewController(BaseController): self.selectedViewer.fitScale() self.referenceViewer.fitScale() - self.parent.buttonBestFit.setEnabled(False) - self.parent.buttonZoomOut.setEnabled(False) - self.parent.buttonZoomIn.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(True) + 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 @@ -417,12 +537,12 @@ class GraphicsViewController(BaseController): self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: # currently selected file is the actual reference file self.referencePixmap = QPixmap() - self.parent.buttonImgSwap.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(True) + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) else: self.referencePixmap = QPixmap(str(ref.path)) - self.parent.buttonImgSwap.setEnabled(True) - self.parent.buttonNormalSize.setEnabled(True) + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) self.selectedViewer.setImage(self.selectedPixmap) self.referenceViewer.setImage(self.referencePixmap) @@ -469,10 +589,10 @@ class GraphicsViewController(BaseController): # self.centerViews() #FIXME move buttons somwhere else - self.parent.buttonZoomIn.setEnabled(False) - self.parent.buttonZoomOut.setEnabled(False) - self.parent.buttonBestFit.setEnabled(False) # active mode by default - self.parent.buttonNormalSize.setEnabled(True) + self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) + self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) def resetViewersState(self): """No item from the model, disable and clear everything.""" @@ -490,11 +610,11 @@ class GraphicsViewController(BaseController): # self.centerViews() #FIXME move buttons somwhere else - self.parent.buttonZoomIn.setEnabled(False) - self.parent.buttonZoomOut.setEnabled(False) - self.parent.buttonBestFit.setEnabled(False) # active mode by default - self.parent.buttonImgSwap.setEnabled(False) - self.parent.buttonNormalSize.setEnabled(False) + self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) + self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) + self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) self.selectedViewer.setImage(self.selectedPixmap) # null self.selectedViewer.setEnabled(False) @@ -517,13 +637,6 @@ class GraphicsViewController(BaseController): self.selectedViewer.centerOn(self.selectedViewer._centerPoint) - # self.selectedViewer.updateCenterPoint() - # self.referenceViewer.setCenter(self.selectedViewer.getCenter()) - # self.selectedViewer.setCenter(self.referenceViewer.getCenter()) - # self.referenceViewer.setCenter(self.selectedViewer.getCenter()) - # The other is automatically updated via sigals - # self.selectedViewer.adjustScrollBarsFactor(factor) - @@ -1156,7 +1269,7 @@ class GraphicsViewViewer(QGraphicsView): super().mouseMoveEvent(event) def updateCenterPoint(self): - self._centerPoint = self.mapToScene( self.viewport().rect().center()) + self._centerPoint = self.mapToScene( self.rect().center()) def wheelEvent(self, event): if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE: From e7b3252534c94328af46e092db9b42abe9dbce1e Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 29 Jun 2020 22:31:43 +0200 Subject: [PATCH 20/61] Cleanup of details table --- qt/details_table.py | 4 +- qt/pe/details_dialog.py | 116 ++++-------- qt/pe/image_viewer.py | 400 ++++++++++++++-------------------------- 3 files changed, 174 insertions(+), 346 deletions(-) diff --git a/qt/details_table.py b/qt/details_table.py index 6977a2b4..f02e479b 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QAbstractTableModel -from PyQt5.QtWidgets import QHeaderView, QTableView +from PyQt5.QtWidgets import QHeaderView, QTableView, QAbstractItemView from hscommon.trans import trget @@ -51,9 +51,11 @@ class DetailsTable(QTableView): QTableView.__init__(self, *args) self.setAlternatingRowColors(True) self.setSelectionBehavior(QTableView.SelectRows) + self.setSelectionMode(QTableView.SingleSelection) self.setShowGrid(False) self.setWordWrap(False) + def setModel(self, model): QTableView.setModel(self, model) # The model needs to be set to set header stuff diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index c653713a..eb480815 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,20 +4,21 @@ # 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 Qt, QSize, pyqtSlot, pyqtSignal -from PyQt5.QtWidgets import (QLayout, QVBoxLayout, QAbstractItemView, QHBoxLayout, - QSizePolicy, QGridLayout, QWidget, QSpacerItem, QSplitter, QFrame ) +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtWidgets import ( + QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame) from hscommon.trans import trget -from hscommon import desktop from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable -from qtlib.util import createActions -from qt.pe.image_viewer import ( - ViewerToolBar, QWidgetImageViewer, ScrollAreaImageViewer, GraphicsViewViewer, - QWidgetController, ScrollAreaController, GraphicsViewController) +from .image_viewer import ( + ViewerToolBar, QWidgetImageViewer, + ScrollAreaImageViewer, GraphicsViewViewer, + QWidgetController, ScrollAreaController, + GraphicsViewController) tr = trget("ui") + class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): self.vController = None @@ -27,47 +28,27 @@ class DetailsDialog(DetailsDialogBase): self.setWindowTitle(tr("Details")) self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) - - # self.verticalLayout = QVBoxLayout(self) - # self.verticalLayout.setSpacing(0) - # self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.splitter = QSplitter(Qt.Vertical, self) self.setCentralWidget(self.splitter) self.topFrame = QFrame() self.topFrame.setFrameShape(QFrame.StyledPanel) - self.horizontalLayout = QGridLayout() # Minimum width for the toolbar in the middle: self.horizontalLayout.setColumnMinimumWidth(1, 10) self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout.setColumnStretch(0,24) - self.horizontalLayout.setColumnStretch(1,1) - self.horizontalLayout.setColumnStretch(2,24) + self.horizontalLayout.setColumnStretch(0, 32) + # Smaller value for the toolbar in the middle to avoid excessive resize + self.horizontalLayout.setColumnStretch(1, 2) + self.horizontalLayout.setColumnStretch(2, 32) + # This avoids toolbar getting incorrectly partially hidden when window resizes + self.horizontalLayout.setRowStretch(0, 1) + self.horizontalLayout.setRowStretch(1, 24) + self.horizontalLayout.setRowStretch(2, 1) + self.horizontalLayout.setSpacing(1) # probably not important - # This avoids toolbar getting incorrectly resized when window resizes - self.horizontalLayout.setRowStretch(0,1) - self.horizontalLayout.setRowStretch(1,24) - self.horizontalLayout.setRowStretch(2,1) - - self.horizontalLayout.setSpacing(1) - - self.selectedImageViewer = GraphicsViewViewer(self, "selectedImage") - # self.selectedImage = QLabel(self) - # sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - # sizePolicy.setHorizontalStretch(0) - # sizePolicy.setVerticalStretch(0) - # sizePolicy.setHeightForWidth( - # self.selectedImage.sizePolicy().hasHeightForWidth() - # ) - # self.selectedImage.setSizePolicy(sizePolicy) - # self.selectedImage.setScaledContents(False) - # self.selectedImage.setAlignment(Qt.AlignCenter) - # # self.horizontalLayout.addWidget(self.selectedImage) + self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1) - - # We use different types of controller depending on the - # underlying widgets we use to display images - # because their interface and methods might differ + # Use a specific type of controller depending on the underlying viewer type if isinstance(self.selectedImageViewer, QWidgetImageViewer): self.vController = QWidgetController(self) elif isinstance(self.selectedImageViewer, ScrollAreaImageViewer): @@ -75,63 +56,34 @@ class DetailsDialog(DetailsDialogBase): elif isinstance(self.selectedImageViewer, GraphicsViewViewer): self.vController = GraphicsViewController(self) - # self.horizontalLayout.addItem(QSpacerItem(5,0, QSizePolicy.Minimum), - # 1, 3, 1, 1, Qt.Alignment(Qt.AlignRight)) - self.verticalToolBar = ViewerToolBar(self, self.vController) - # self.verticalToolBar.setMaximumWidth(10) self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) - # self.subVLayout = QVBoxLayout(self) - # self.subVLayout.addWidget(self.verticalToolBar) - # self.horizontalLayout.addLayout(self.subVLayout) - self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) - self.referenceImageViewer = GraphicsViewViewer(self, "referenceImage") - # self.referenceImage = QLabel(self) - # sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - # sizePolicy.setHorizontalStretch(0) - # sizePolicy.setVerticalStretch(0) - # self.verticalToolBar.setSizePolicy(sizePolicy) - # sizePolicy.setHeightForWidth( - # self.referenceImage.sizePolicy().hasHeightForWidth() - # ) - # self.referenceImageViewer.setSizePolicy(sizePolicy) - # self.referenceImageViewer.setAlignment(Qt.AlignCenter) + self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) - # self.verticalLayout.addLayout(self.horizontalLayout) self.topFrame.setLayout(self.horizontalLayout) self.splitter.addWidget(self.topFrame) - - # container = QWidget(self) - # container.setLayout(self.horizontalLayout) - # self.setLayout(self.horizontalLayout) - # self.splitter.addWidget(self) self.splitter.setStretchFactor(0, 8) self.tableView = DetailsTable(self) sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - # sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth()) self.tableView.setSizePolicy(sizePolicy) # self.tableView.setMinimumSize(QSize(0, 190)) # self.tableView.setMaximumSize(QSize(16777215, 190)) - self.tableView.setAlternatingRowColors(True) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) - # self.verticalLayout.addLayout(self.tableView) - self.splitter.addWidget(self.tableView) self.splitter.setStretchFactor(1, 1) - - # self.tableView.hide() - - self.vController.setupViewers(self.selectedImageViewer, self.referenceImageViewer) + # Late population needed here for connections to the toolbar + self.vController.setupViewers( + self.selectedImageViewer, self.referenceImageViewer) def _update(self): - if self.vController is None: # Not yet constructed! + if self.vController is None: # Not yet constructed! return if not self.app.model.selected_dupes: # No item from the model, disable and clear everything. @@ -154,14 +106,13 @@ class DetailsDialog(DetailsDialogBase): 0, self.selectedImageViewer.size().width()) self.horizontalLayout.setColumnMinimumWidth( 2, self.selectedImageViewer.size().width()) - # This works when expanding but it's ugly: -# if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): -# print(f"Before selected size: {self.selectedImageViewer.size()}\n\ -# Before reference size: {self.referenceImageViewer.size()}") -# self.selectedImageViewer.resize(self.referenceImageViewer.size()) -# print(f"After selected size: {self.selectedImageViewer.size()}\n\ -# After reference size: {self.referenceImageViewer.size()}") + if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): + # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", + # f"""Before reference size: {self.referenceImageViewer.size()}""") + self.selectedImageViewer.resize(self.referenceImageViewer.size()) + # print(f"""After selected size: {self.selectedImageViewer.size()}\n""", + # f"""After reference size: {self.referenceImageViewer.size()}""") if self.vController is None or not self.vController.bestFit: return @@ -170,8 +121,10 @@ class DetailsDialog(DetailsDialogBase): def show(self): # Compute the maximum size the table view can reach + # Assuming all rows below headers have the same height self.tableView.setMaximumHeight( - self.tableView.rowHeight(1) * self.tableModel.model.row_count()\ + self.tableView.rowHeight(1) + * self.tableModel.model.row_count() + self.tableView.verticalHeader().sectionSize(0)) DetailsDialogBase.show(self) self._update() @@ -181,4 +134,3 @@ class DetailsDialog(DetailsDialogBase): DetailsDialogBase.refresh(self) if self.isVisible(): self._update() - diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index c7783c5d..677071e0 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -3,9 +3,11 @@ # 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, QTransform, QIcon, QKeySequence -from PyQt5.QtWidgets import ( QToolBar, QToolButton, QAction, QLabel, QSizePolicy, QWidget, QScrollArea, - QScrollBar, QApplication, QAbstractScrollArea, QStyle) +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") @@ -13,9 +15,22 @@ 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__() + super().__init__(parent) self.parent = parent self.controller = controller self.setupActions(controller) @@ -26,21 +41,8 @@ class ViewerToolBar(QToolBar): self.buttonNormalSize.setEnabled(False) self.buttonBestFit.setEnabled(False) - def createActions(self, actions, target): - # TODO try with QWidgetAction() instead in order to have - # the popup menu work in the toolbar (if resized below minimum height) - # actions = [(name, shortcut, icon, desc, func)] - for name, shortcut, icon, desc, func in actions: - action = QAction(target) - if icon: - action.setIcon(QIcon(QPixmap(":/" + icon))) - if shortcut: - action.setShortcut(shortcut) - action.setText(desc) - action.triggered.connect(func) - setattr(target, name, action) - def setupActions(self, controller): + # actions = [(name, shortcut, icon, desc, func)] ACTIONS = [ ( "actionZoomIn", @@ -58,27 +60,29 @@ class ViewerToolBar(QToolBar): ), ( "actionNormalSize", - QKeySequence.Refresh, + tr("Ctrl+/"), "zoom-original", tr("Normal size"), controller.zoomNormalSize, ), ( "actionBestFit", - tr("Ctrl+p"), + tr("Ctrl+*"), "zoom-best-fit", tr("Best fit"), controller.zoomBestFit, ) ] - self.createActions(ACTIONS, self.parent) - + # 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.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) @@ -86,29 +90,30 @@ class ViewerToolBar(QToolBar): self.buttonZoomIn = QToolButton(self) self.buttonZoomIn.setToolButtonStyle(Qt.ToolButtonIconOnly) - self.buttonZoomIn.setDefaultAction(self.parent.actionZoomIn) - self.buttonZoomIn.setText('ZoomIn') - self.buttonZoomIn.setIcon(QIcon.fromTheme('zoom-in')) + 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.parent.actionZoomOut) - self.buttonZoomOut.setText('ZoomOut') - self.buttonZoomOut.setIcon(QIcon.fromTheme('zoom-out')) + 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.parent.actionNormalSize) - self.buttonNormalSize.setText('Normal Size') - self.buttonNormalSize.setIcon(QIcon.fromTheme('zoom-original')) + 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.parent.actionBestFit) - self.buttonBestFit.setText('BestFit') - self.buttonBestFit.setIcon(QIcon.fromTheme('zoom-best-fit')) + 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) @@ -134,8 +139,7 @@ class BaseController(QObject): self.scaledReferencePixmap = QPixmap() self.current_scale = 1.0 self.bestFit = True - self.wantScrollBars = True - self.parent = parent #To change buttons' states + self.parent = parent # To change buttons' states self.cached_group = None def setupViewers(self, selectedViewer, referenceViewer): @@ -150,17 +154,15 @@ class BaseController(QObject): self.referenceViewer.connectMouseSignals() def updateView(self, ref, dupe, group): - # Keep current scale accross dupes from the same 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.current_scale = 1.0 - self.cached_group = group self.selectedPixmap = QPixmap(str(dupe.path)) - if ref is dupe: # currently selected file is the actual reference file + if ref is dupe: # currently selected file is the actual reference file self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) @@ -169,22 +171,20 @@ class BaseController(QObject): 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): - # FIXME this is called on every resize event, + # WARNING this is called on every resize event, ignore_update = self.referencePixmap.isNull() if ignore_update: self.selectedViewer.ignore_signal = True - selected_size = self._updateImage( - self.selectedPixmap, self.scaledSelectedPixmap, - self.selectedViewer, None, same_group) # 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) @@ -192,12 +192,12 @@ class BaseController(QObject): self.selectedViewer.ignore_signal = False def _updateImage(self, pixmap, scaledpixmap, viewer, target_size=None, same_group=False): - # FIXME this is called on every resize event, split into a separate function + # WARNING this is called on every resize event, might need to split + # into a separate function depending on the implementation used if pixmap.isNull(): - # disable the blank widget. + # This should disable the blank widget viewer.setImage(pixmap) return - target_size = viewer.size() if not viewer.bestFit: if same_group: @@ -225,20 +225,15 @@ class BaseController(QObject): 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() - - #FIXME move buttons somwhere else self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) - self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default 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.""" @@ -257,16 +252,15 @@ class BaseController(QObject): self.referenceViewer.scaleAt(1.0) self.centerViews() - #FIXME move buttons somwhere else + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) - self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default - self.parent.verticalToolBar.buttonImgSwap.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.setImage(self.selectedPixmap) # null self.selectedViewer.setEnabled(False) - self.referenceViewer.setImage(self.referencePixmap) # null + self.referenceViewer.setImage(self.referencePixmap) # null self.referenceViewer.setEnabled(False) @pyqtSlot() @@ -296,8 +290,8 @@ class BaseController(QObject): 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.buttonBestFit.setEnabled(self.bestFit is False) 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): @@ -313,14 +307,18 @@ class BaseController(QObject): 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) + 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.buttonBestFit.setEnabled(False) - self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) 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 @@ -340,9 +338,9 @@ class BaseController(QObject): self.selectedViewer.scaleToNormalSize() self.referenceViewer.scaleToNormalSize() - self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) 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): @@ -371,7 +369,7 @@ class QWidgetController(BaseController): @pyqtSlot() def swapImages(self): - self.selectedViewer.getPixmap().swap(self.referenceViewer.getPixmap()) + self.selectedViewer._pixmap.swap(self.referenceViewer._pixmap) self.selectedViewer.centerViewAndUpdate() self.referenceViewer.centerViewAndUpdate() super().swapImages() @@ -443,18 +441,16 @@ class ScrollAreaController(BaseController): def scaleImagesBy(self, factor): super().scaleImagesBy(factor) # The other is automatically updated via sigals - # self.selectedViewer.adjustScrollBarsFactor(factor) + self.selectedViewer.adjustScrollBarsFactor(factor) @pyqtSlot() def zoomBestFit(self): - # Disable scrollbars to avoid GridLayout size rounding "error" + # Disable scrollbars to avoid GridLayout size rounding glitch super().zoomBestFit() - print("toggling scrollbars") self.selectedViewer.toggleScrollBars() self.referenceViewer.toggleScrollBars() - class GraphicsViewController(BaseController): """Specialized version fro QGraphicsView-based viewers.""" def __init__(self, parent): @@ -464,27 +460,26 @@ class GraphicsViewController(BaseController): 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.getCenter()) + self.selectedViewer.setCenter(self.referenceViewer._centerPoint) else: - self.referenceViewer.setCenter(self.selectedViewer.getCenter()) + 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(self.referenceViewer.getCenter()) + self.selectedViewer.setCenter(newCenter) else: self.referenceViewer.scaleBy(factor) - self.referenceViewer.setCenter(self.selectedViewer.getCenter()) - - # self.selectedViewer.adjustScrollBarsScaled(delta) - # Signal from scrollbars will automatically change the other: - # self.referenceViewer.adjustScrollBarsScaled(delta) + self.referenceViewer.setCenter(newCenter) @pyqtSlot(int) def onVScrollBarChanged(self, value): @@ -516,10 +511,8 @@ class GraphicsViewController(BaseController): """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) @@ -531,11 +524,10 @@ class GraphicsViewController(BaseController): 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 + 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) @@ -547,7 +539,6 @@ class GraphicsViewController(BaseController): self.selectedViewer.setImage(self.selectedPixmap) self.referenceViewer.setImage(self.referencePixmap) self.updateBothImages(same_group) - self.centerViews(same_group and self.referencePixmap.isNull()) def updateBothImages(self, same_group=False): """This is called only during resize events and while bestFit.""" @@ -585,13 +576,10 @@ class GraphicsViewController(BaseController): self.selectedViewer.fitScale() self.referenceViewer.fitScale() - # self.centerViews() - - #FIXME move buttons somwhere else self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) - self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default + self.parent.verticalToolBar.buttonBestFit.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) def resetViewersState(self): @@ -608,17 +596,15 @@ class GraphicsViewController(BaseController): self.selectedViewer.resetCenter() self.referenceViewer.resetCenter() # self.centerViews() - - #FIXME move buttons somwhere else self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) - self.parent.verticalToolBar.buttonBestFit.setEnabled(False) # active mode by default + 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.setImage(self.selectedPixmap) # null self.selectedViewer.setEnabled(False) - self.referenceViewer.setImage(self.referencePixmap) # null + self.referenceViewer.setImage(self.referencePixmap) # null self.referenceViewer.setEnabled(False) @pyqtSlot(float) @@ -626,24 +612,13 @@ class GraphicsViewController(BaseController): self.selectedViewer.updateCenterPoint() self.referenceViewer.updateCenterPoint() super().scaleImagesBy(factor) - # self.selectedViewer.setNewCenter(self.selectedViewer._scene.sceneRect().center()) - # self.selectedViewer._centerPoint = self.selectedViewer.viewport().rect().center() - - - # self.referenceViewer._mousePanningDelta = self.selectedViewer._mousePanningDelta - # # self.selectedViewer._mousePanningDelta = self.referenceViewer._mousePanningDelta - # self.selectedViewer.adjustScrollBarsAuto() - # self.referenceViewer.adjustScrollBarsAuto() - self.selectedViewer.centerOn(self.selectedViewer._centerPoint) - - + # Scrollbars sync themselves here class QWidgetImageViewer(QWidget): - """Use a QPixmap, but no scrollbars.""" - #FIXME: panning while zoomed-in is broken (due to delta not interpolated right?) - #TODO: keyboard shortcuts for navigation + """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) @@ -666,16 +641,13 @@ class QWidgetImageViewer(QWidget): def __repr__(self): return f'{self._instance_name}' - def getPixmap(self): - return self._pixmap - 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.scaleImagesBy) + self.controller.scaleImagesBy) def disconnectMouseSignals(self): if self._dragConnection: @@ -700,7 +672,6 @@ class QWidgetImageViewer(QWidget): def changeEvent(self, event): if event.type() == QEvent.EnabledChange: - # print(f"{self} is now {'enabled' if self.isEnabled() else 'disabled'}") if self.isEnabled(): self.connectMouseSignals() return @@ -727,8 +698,8 @@ class QWidgetImageViewer(QWidget): event.ignore() return - self._mousePanningDelta += (event.pos() - self._lastMouseClickPoint) \ - * 1.0 / self.current_scale + self._mousePanningDelta += ( + event.pos() - self._lastMouseClickPoint) * 1.0 / self.current_scale self._lastMouseClickPoint = event.pos() if self._drag: self.mouseDragged.emit(self._mousePanningDelta) @@ -752,11 +723,11 @@ class QWidgetImageViewer(QWidget): if event.angleDelta().y() > 0: if self.current_scale > MAX_SCALE: return - self.mouseWheeled.emit(1.25) # zoom-in + self.mouseWheeled.emit(1.25) # zoom-in else: if self.current_scale < MIN_SCALE: return - self.mouseWheeled.emit(0.8) # zoom-out + self.mouseWheeled.emit(0.8) # zoom-out def setImage(self, pixmap): if pixmap.isNull(): @@ -800,11 +771,10 @@ class QWidgetImageViewer(QWidget): def onDraggedMouse(self, delta): self._mousePanningDelta = delta self.update() - # print(f"{self} received drag signal from {self.sender()}") class ScalablePixmap(QWidget): - """Container for a pixmap that scales up very fast""" + """Container for a pixmap that scales up very fast, used in ScrollAreaImageViewer.""" def __init__(self, parent): super().__init__(parent) self._pixmap = QPixmap() @@ -812,30 +782,20 @@ class ScalablePixmap(QWidget): 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.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.update() + # 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 - # 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""" + """Implementation using a pixmap container in a simple scroll area.""" mouseDragged = pyqtSignal(QPoint) mouseWheeled = pyqtSignal(float, QPointF) @@ -852,8 +812,6 @@ class ScrollAreaImageViewer(QScrollArea): 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 @@ -861,35 +819,26 @@ class ScrollAreaImageViewer(QScrollArea): 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.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 getPixmap(self): - return self._pixmap - 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: @@ -919,7 +868,6 @@ class ScrollAreaImageViewer(QScrollArea): def connectScrollBars(self): """Only call once controller is connected.""" # Cyclic connections are handled by Qt - return self._verticalScrollBar.valueChanged.connect( self.controller.onVScrollBarChanged, Qt.UniqueConnection) self._horizontalScrollBar.valueChanged.connect( @@ -949,7 +897,6 @@ class ScrollAreaImageViewer(QScrollArea): self._lastMouseClickPoint = event.pos() self.mouseDragged.emit(delta) super().mouseMoveEvent(event) - # self.update() def mouseReleaseEvent(self, event): if self.bestFit: @@ -966,11 +913,11 @@ class ScrollAreaImageViewer(QScrollArea): event.ignore() return oldScale = self.current_scale - if event.angleDelta().y() > 0: # zoom-in + if event.angleDelta().y() > 0: # zoom-in if oldScale < MAX_SCALE: self.current_scale *= 1.25 else: - if oldScale > MIN_SCALE: # zoom-out + if oldScale > MIN_SCALE: # zoom-out self.current_scale *= 0.8 if oldScale == self.current_scale: return @@ -981,7 +928,7 @@ class ScrollAreaImageViewer(QScrollArea): def setImage(self, pixmap): self._pixmap = pixmap - self.label.setPixmap(pixmap) + self.label._pixmap = pixmap self.label.update() self.label.adjustSize() if pixmap.isNull(): @@ -1000,7 +947,7 @@ class ScrollAreaImageViewer(QScrollArea): def setCachedPixmap(self): """In case we have changed the cached pixmap, reset it.""" - self.label.setPixmap(self._pixmap) + self.label._pixmap = self._pixmap self.label.update() def shouldBeActive(self): @@ -1008,42 +955,10 @@ class ScrollAreaImageViewer(QScrollArea): def scaleBy(self, factor): 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) - - # pointBeforeScale = QPoint(self.viewport().width() / 2, - # self.viewport().height() / 2) - pointBeforeScale = self.label.rect().center() - screenCenter = self.rect().center() - - 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! + # factor has to be either 1.25 or 0.8 here self.label.resize(self.label.size().__imul__(factor)) - # self.label.updateGeometry() - 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.centerOn(newCenter) - - # self.adjustScrollBarCentered() def scaleAt(self, scale): self.current_scale = scale @@ -1052,26 +967,15 @@ class ScrollAreaImageViewer(QScrollArea): self.label.update() # self.label.adjustSize() - def centerOn(self, position): - # TODO here make widget move without the scrollbars if possible - - 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))) + 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.""" @@ -1098,48 +1002,36 @@ class ScrollAreaImageViewer(QScrollArea): """ Resets origin """ self._mousePanningDelta = QPoint() self.current_scale = 1.0 - # self.scaleBy(1.0) - # self.label.update() # already called in scaleBy + # self.scaleAt(1.0) def setCenter(self, point): self._lastMouseClickPoint = point - def getCenter(self): - return self._lastMouseClickPoint - def sizeHint(self): return self.viewport().rect().size() - # def viewportSizeHint(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.label.update() + 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 - # self.label.move(self.label.pos() + delta) - # self.label.update() - # Signal from scrollbars had already synced the values here self.adjustScrollBarsAuto() - # print(f"{self} onDraggedMouse slot with delta {delta}") + # 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'}") + # 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 using a more full fledged class.""" mouseDragged = pyqtSignal() @@ -1167,7 +1059,7 @@ class GraphicsViewViewer(QGraphicsView): 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) @@ -1175,7 +1067,7 @@ class GraphicsViewViewer(QGraphicsView): self.setScene(self._scene) self._scene.addItem(self._item) self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) - self.matrix = QTransform() + # self.matrix = QTransform() self._horizontalScrollBar = self.horizontalScrollBar() self._verticalScrollBar = self.verticalScrollBar() self.ignore_signal = False @@ -1188,16 +1080,16 @@ class GraphicsViewViewer(QGraphicsView): self.setResizeAnchor(QGraphicsView.AnchorViewCenter) self.setAlignment(Qt.AlignCenter) - self.setViewportUpdateMode (QGraphicsView.FullViewportUpdate) + self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) self.setMouseTracking(True) def connectMouseSignals(self): if not self._dragConnection: self._dragConnection = self.mouseDragged.connect( - self.controller.syncCenters) + self.controller.syncCenters) if not self._wheelConnection: self._wheelConnection = self.mouseWheeled.connect( - self.controller.onMouseWheel) + self.controller.onMouseWheel) def disconnectMouseSignals(self): if self._dragConnection: @@ -1243,7 +1135,6 @@ class GraphicsViewViewer(QGraphicsView): self.setMouseTracking(True) # We need to propagate to scrollbars, so we send back up super().mousePressEvent(event) - # event.accept() def mouseReleaseEvent(self, event): if self.bestFit: @@ -1261,7 +1152,6 @@ class GraphicsViewViewer(QGraphicsView): event.ignore() return if self._drag: - delta = (event.pos() - self._lastMouseClickPoint) self._lastMouseClickPoint = event.pos() # We can simply rely on the scrollbar updating each other here # self.mouseDragged.emit() @@ -1269,7 +1159,7 @@ class GraphicsViewViewer(QGraphicsView): super().mouseMoveEvent(event) def updateCenterPoint(self): - self._centerPoint = self.mapToScene( self.rect().center()) + self._centerPoint = self.mapToScene(self.rect().center()) def wheelEvent(self, event): if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE: @@ -1277,52 +1167,40 @@ class GraphicsViewViewer(QGraphicsView): return pointBeforeScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos()))) # Get the original screen centerpoint - screenCenter = QPointF(self.mapToScene( self.rect().center() )) - + 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 + 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 + # 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) - # offset = -QRectF(pixmap.rect()).center() - # 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(QRectF(self._pixmap.rect())) # not sure if this works def centerViewAndUpdate(self): - # self._rect = self.sceneRect() - # self._rect.translate(-self._rect.center()) - # self._item.update() - # self.viewport().update() + # Called from the base controller for Normal Size pass - def setCenter(self, point): self._centerPoint = point self.centerOn(self._centerPoint) - def getCenter(self): - return self._centerPoint - def resetCenter(self): """ Resets origin """ self._mousePanningDelta = QPointF() self.current_scale = 1.0 - # self.update() - # self.setCenter(self._scene.sceneRect().center()) def setNewCenter(self, position): self._centerPoint = position @@ -1337,7 +1215,6 @@ class GraphicsViewViewer(QGraphicsView): # self.current_scale = scale if scale == 1.0: self.resetScale() - # self.setTransform( QTransform() ) self.scale(scale, scale) @@ -1350,13 +1227,12 @@ class GraphicsViewViewer(QGraphicsView): def resetScale(self): # self.setTransform( QTransform() ) - self.resetTransform() # probably same as above - self.setCenter( self.scene().sceneRect().center() ) - # self.scaleChanged.emit( self.transform().m22() ) + 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 ) + super().fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio) self.setNewCenter(self._scene.sceneRect().center()) @pyqtSlot() @@ -1377,20 +1253,18 @@ class GraphicsViewViewer(QGraphicsView): def sizeHint(self): return self.viewport().rect().size() - # def viewportSizeHint(self): - # return self.viewport().rect().size() - 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))) + 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()) \ No newline at end of file + self.verticalScrollBar().value() - self._mousePanningDelta.y()) From 4ee9479a5fc1a42272af374d785400b84155f854 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 2 Jul 2020 22:49:45 +0200 Subject: [PATCH 21/61] Add image comparison features to details dialog * Add splitter in order to hide the details table. * Add a toolbar to the Details Dialog window to allow for better image comparisons: zoom in/out, swap pixmaps in place, best-fit-to-viewport. Scrollbars and viewports are synchronized. --- .gitignore | 4 +- qt/details_dialog.py | 4 +- qt/details_table.py | 6 +- qt/pe/details_dialog.py | 148 ++--- qt/pe/image_viewer.py | 1271 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1359 insertions(+), 74 deletions(-) create mode 100644 qt/pe/image_viewer.py diff --git a/.gitignore b/.gitignore index 5e7c7043..774f1fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ cocoa/autogen *.pyd *.exe -*.spec \ No newline at end of file +*.spec + +.vscode diff --git a/qt/details_dialog.py b/qt/details_dialog.py index d117f098..57cc650b 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -7,12 +7,12 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDialog +from PyQt5.QtWidgets import QMainWindow from .details_table import DetailsModel -class DetailsDialog(QDialog): +class DetailsDialog(QMainWindow): def __init__(self, parent, app, **kwargs): super().__init__(parent, Qt.Tool, **kwargs) self.app = app diff --git a/qt/details_table.py b/qt/details_table.py index 1e353f17..f02e479b 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QAbstractTableModel -from PyQt5.QtWidgets import QHeaderView, QTableView +from PyQt5.QtWidgets import QHeaderView, QTableView, QAbstractItemView from hscommon.trans import trget @@ -51,9 +51,11 @@ class DetailsTable(QTableView): QTableView.__init__(self, *args) self.setAlternatingRowColors(True) self.setSelectionBehavior(QTableView.SelectRows) + self.setSelectionMode(QTableView.SingleSelection) self.setShowGrid(False) self.setWordWrap(False) + def setModel(self, model): QTableView.setModel(self, model) # The model needs to be set to set header stuff @@ -61,7 +63,7 @@ class DetailsTable(QTableView): hheader.setHighlightSections(False) hheader.setStretchLastSection(False) hheader.resizeSection(0, 100) - hheader.setSectionResizeMode(0, QHeaderView.Fixed) + hheader.setSectionResizeMode(0, QHeaderView.Interactive) hheader.setSectionResizeMode(1, QHeaderView.Stretch) hheader.setSectionResizeMode(2, QHeaderView.Stretch) vheader = self.verticalHeader() diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 29c60899..07ecdfcb 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -5,109 +5,119 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QSize -from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import ( - QVBoxLayout, - QAbstractItemView, - QHBoxLayout, - QLabel, - QSizePolicy, -) + QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame) from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable - +from .image_viewer import ( + ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController) tr = trget("ui") class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): - DetailsDialogBase.__init__(self, parent, app) - self.selectedPixmap = None - self.referencePixmap = None + self.vController = None + super().__init__(parent, app) def _setupUi(self): self.setWindowTitle(tr("Details")) - self.resize(502, 295) + self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) - self.verticalLayout = QVBoxLayout(self) - self.verticalLayout.setSpacing(0) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.setSpacing(4) - self.selectedImage = QLabel(self) - sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.selectedImage.sizePolicy().hasHeightForWidth() - ) - self.selectedImage.setSizePolicy(sizePolicy) - self.selectedImage.setScaledContents(False) - self.selectedImage.setAlignment(Qt.AlignCenter) - self.horizontalLayout.addWidget(self.selectedImage) - self.referenceImage = QLabel(self) - sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.referenceImage.sizePolicy().hasHeightForWidth() - ) - self.referenceImage.setSizePolicy(sizePolicy) - self.referenceImage.setAlignment(Qt.AlignCenter) - self.horizontalLayout.addWidget(self.referenceImage) - self.verticalLayout.addLayout(self.horizontalLayout) + self.splitter = QSplitter(Qt.Vertical, self) + self.setCentralWidget(self.splitter) + self.topFrame = QFrame() + self.topFrame.setFrameShape(QFrame.StyledPanel) + self.horizontalLayout = QGridLayout() + # Minimum width for the toolbar in the middle: + self.horizontalLayout.setColumnMinimumWidth(1, 10) + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.horizontalLayout.setColumnStretch(0, 32) + # Smaller value for the toolbar in the middle to avoid excessive resize + self.horizontalLayout.setColumnStretch(1, 2) + self.horizontalLayout.setColumnStretch(2, 32) + # This avoids toolbar getting incorrectly partially hidden when window resizes + self.horizontalLayout.setRowStretch(0, 1) + self.horizontalLayout.setRowStretch(1, 24) + self.horizontalLayout.setRowStretch(2, 1) + self.horizontalLayout.setSpacing(1) # probably not important + + self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage") + self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1) + # Use a specific type of controller depending on the underlying viewer type + self.vController = ScrollAreaController(self) + + self.verticalToolBar = ViewerToolBar(self, self.vController) + self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical)) + self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter) + + self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage") + self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1) + self.topFrame.setLayout(self.horizontalLayout) + self.splitter.addWidget(self.topFrame) + self.splitter.setStretchFactor(0, 8) + self.tableView = DetailsTable(self) - sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth()) self.tableView.setSizePolicy(sizePolicy) - self.tableView.setMinimumSize(QSize(0, 188)) - self.tableView.setMaximumSize(QSize(16777215, 190)) + # self.tableView.setMinimumSize(QSize(0, 190)) + # self.tableView.setMaximumSize(QSize(16777215, 190)) self.tableView.setAlternatingRowColors(True) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) - self.verticalLayout.addWidget(self.tableView) + self.splitter.addWidget(self.tableView) + self.splitter.setStretchFactor(1, 1) + # Late population needed here for connections to the toolbar + self.vController.setupViewers( + self.selectedImageViewer, self.referenceImageViewer) def _update(self): + if self.vController is None: # Not yet constructed! + return if not self.app.model.selected_dupes: + # No item from the model, disable and clear everything. + self.vController.resetViewersState() return dupe = self.app.model.selected_dupes[0] group = self.app.model.results.get_group_of_duplicate(dupe) ref = group.ref - self.selectedPixmap = QPixmap(str(dupe.path)) - if ref is dupe: - self.referencePixmap = None - else: - self.referencePixmap = QPixmap(str(ref.path)) - self._updateImages() - - def _updateImages(self): - if self.selectedPixmap is not None: - target_size = self.selectedImage.size() - scaledPixmap = self.selectedPixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation - ) - self.selectedImage.setPixmap(scaledPixmap) - else: - self.selectedImage.setPixmap(QPixmap()) - if self.referencePixmap is not None: - target_size = self.referenceImage.size() - scaledPixmap = self.referencePixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation - ) - self.referenceImage.setPixmap(scaledPixmap) - else: - self.referenceImage.setPixmap(QPixmap()) + self.vController.updateView(ref, dupe, group) # --- Override def resizeEvent(self, event): - self._updateImages() + # HACK referenceViewer might be 1 pixel shorter in width + # due to the toolbar in the middle keeping the same width, + # so resizing in the GridLayout's engine leads to not enough space + # left for the panel on the right. + # This ensures same size while shrinking at least: + self.horizontalLayout.setColumnMinimumWidth( + 0, self.selectedImageViewer.size().width()) + self.horizontalLayout.setColumnMinimumWidth( + 2, self.selectedImageViewer.size().width()) + # This works when expanding but it's ugly: + if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): + # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", + # f"""Before reference size: {self.referenceImageViewer.size()}""") + self.selectedImageViewer.resize(self.referenceImageViewer.size()) + # print(f"""After selected size: {self.selectedImageViewer.size()}\n""", + # f"""After reference size: {self.referenceImageViewer.size()}""") + + if self.vController is None or not self.vController.bestFit: + return + # Only update the scaled down pixmaps + self.vController.updateBothImages() def show(self): + # Compute the maximum size the table view can reach + # Assuming all rows below headers have the same height + self.tableView.setMaximumHeight( + self.tableView.rowHeight(1) + * self.tableModel.model.row_count() + + self.tableView.verticalHeader().sectionSize(0)) DetailsDialogBase.show(self) self._update() diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py new file mode 100644 index 00000000..54366e98 --- /dev/null +++ b/qt/pe/image_viewer.py @@ -0,0 +1,1271 @@ +# 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()) From b0a256f0d49297229b6bff4f6769dfe0feb28159 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 2 Jul 2020 23:09:02 +0200 Subject: [PATCH 22/61] Fix flake8 minor issues --- qt/details_table.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/qt/details_table.py b/qt/details_table.py index f02e479b..854a1595 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QAbstractTableModel -from PyQt5.QtWidgets import QHeaderView, QTableView, QAbstractItemView +from PyQt5.QtWidgets import QHeaderView, QTableView from hscommon.trans import trget @@ -55,7 +55,6 @@ class DetailsTable(QTableView): self.setShowGrid(False) self.setWordWrap(False) - def setModel(self, model): QTableView.setModel(self, model) # The model needs to be set to set header stuff From 56912a71084415eac2f447650279d833d9857686 Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 13 Jul 2020 05:06:04 +0200 Subject: [PATCH 23/61] Make details dialog dockable --- qt/app.py | 4 +++- qt/details_dialog.py | 4 ++-- qt/pe/details_dialog.py | 18 +++++++++++------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/qt/app.py b/qt/app.py index ac59dd0a..74b9ab74 100644 --- a/qt/app.py +++ b/qt/app.py @@ -7,7 +7,7 @@ import sys import os.path as op -from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal +from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt from PyQt5.QtGui import QDesktopServices from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox @@ -284,6 +284,8 @@ class DupeGuru(QObject): self.resultWindow.setParent(None) self.resultWindow = ResultWindow(self.directories_dialog, self) self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) + self.resultWindow.addDockWidget( + Qt.BottomDockWidgetArea, self.details_dialog) def show_results_window(self): self.showResultsWindow() diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 57cc650b..3c06a641 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -7,12 +7,12 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QMainWindow +from PyQt5.QtWidgets import QDockWidget from .details_table import DetailsModel -class DetailsDialog(QMainWindow): +class DetailsDialog(QDockWidget): def __init__(self, parent, app, **kwargs): super().__init__(parent, Qt.Tool, **kwargs) self.app = app diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 07ecdfcb..933105b9 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -25,8 +25,8 @@ class DetailsDialog(DetailsDialogBase): self.setWindowTitle(tr("Details")) self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) + self.setAllowedAreas(Qt.AllDockWidgetAreas) self.splitter = QSplitter(Qt.Vertical, self) - self.setCentralWidget(self.splitter) self.topFrame = QFrame() self.topFrame.setFrameShape(QFrame.StyledPanel) self.horizontalLayout = QGridLayout() @@ -73,6 +73,8 @@ class DetailsDialog(DetailsDialogBase): # Late population needed here for connections to the toolbar self.vController.setupViewers( self.selectedImageViewer, self.referenceImageViewer) + # self.setCentralWidget(self.splitter) # only as QMainWindow + self.setWidget(self.splitter) # only as QDockWidget def _update(self): if self.vController is None: # Not yet constructed! @@ -89,15 +91,17 @@ class DetailsDialog(DetailsDialogBase): # --- Override def resizeEvent(self, event): - # HACK referenceViewer might be 1 pixel shorter in width + # HACK This ensures same size while shrinking. + # ReferenceViewer might be 1 pixel shorter in width # due to the toolbar in the middle keeping the same width, # so resizing in the GridLayout's engine leads to not enough space # left for the panel on the right. - # This ensures same size while shrinking at least: - self.horizontalLayout.setColumnMinimumWidth( - 0, self.selectedImageViewer.size().width()) - self.horizontalLayout.setColumnMinimumWidth( - 2, self.selectedImageViewer.size().width()) + # This doesn't work as a QDockWidget however! + # self.horizontalLayout.setColumnMinimumWidth( + # 0, self.selectedImageViewer.size().width()) + # self.horizontalLayout.setColumnMinimumWidth( + # 2, self.selectedImageViewer.size().width()) + # This works when expanding but it's ugly: if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", From 3eddeb6aebc99126e62eb05af60333ba3bd22e82 Mon Sep 17 00:00:00 2001 From: glubsy Date: Tue, 14 Jul 2020 17:37:48 +0200 Subject: [PATCH 24/61] Fix ME/SE details dialogs, add preferences * Fix ME and SE versions of details dialog not displaying their content properly after change to QDockWidget * Add option to toggle titlebar and orientation of titlebar in preferences dialog * Fix setting layout on PE details dialog window while layout already set, by removing the self (parent) reference in constructing the QSplitter --- qt/app.py | 5 ++++- qt/details_dialog.py | 20 +++++++++++++++++++- qt/me/details_dialog.py | 5 ++++- qt/pe/details_dialog.py | 6 +++--- qt/preferences.py | 6 ++++++ qt/preferences_dialog.py | 19 ++++++++++++++++++- qt/se/details_dialog.py | 5 ++++- 7 files changed, 58 insertions(+), 8 deletions(-) diff --git a/qt/app.py b/qt/app.py index 74b9ab74..7a5b60f6 100644 --- a/qt/app.py +++ b/qt/app.py @@ -54,11 +54,11 @@ class DupeGuru(QObject): def _setup(self): core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto self._setupActions() + self.details_dialog = None self._update_options() self.recentResults = Recent(self, "recentResults") self.recentResults.mustOpenItem.connect(self.model.load_from) self.resultWindow = None - self.details_dialog = None self.directories_dialog = DirectoriesDialog(self) self.progress_window = ProgressWindow( self.directories_dialog, self.model.progress_window @@ -152,6 +152,9 @@ class DupeGuru(QObject): self.model.options["match_scaled"] = self.prefs.match_scaled self.model.options["picture_cache_type"] = self.prefs.picture_cache_type + if self.details_dialog: + self.details_dialog.update_options() + # --- Private def _get_details_dialog_class(self): if self.model.app_mode == AppMode.Picture: diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 3c06a641..56555c30 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QDockWidget +from PyQt5.QtWidgets import QDockWidget, QWidget from .details_table import DetailsModel @@ -17,7 +17,9 @@ class DetailsDialog(QDockWidget): super().__init__(parent, Qt.Tool, **kwargs) self.app = app self.model = app.model.details_panel + self.setAllowedAreas(Qt.AllDockWidgetAreas) self._setupUi() + self.update_options() # To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog # has been shown. If it has, we know that our geometry should be saved. self._shown_once = False @@ -36,6 +38,22 @@ class DetailsDialog(QDockWidget): self._shown_once = True super().show() + def update_options(self): + # This disables the title bar (if we had not set one before already) + # essentially making it a simple floating window, not dockable anymore + if not self.app.prefs.details_dialog_titlebar_enabled \ + and not self.titleBarWidget(): + self.setTitleBarWidget(QWidget()) + elif self.titleBarWidget() is not None: + # resets to the default title bar + self.setTitleBarWidget(None) + + features = self.features() + if self.app.prefs.details_dialog_vertical_titlebar: + self.setFeatures(features | QDockWidget.DockWidgetVerticalTitleBar) + elif features & QDockWidget.DockWidgetVerticalTitleBar: + self.setFeatures(features ^ QDockWidget.DockWidgetVerticalTitleBar) + # --- Events def appWillSavePrefs(self): if self._shown_once: diff --git a/qt/me/details_dialog.py b/qt/me/details_dialog.py index 935a34c6..ecb947d0 100644 --- a/qt/me/details_dialog.py +++ b/qt/me/details_dialog.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView +from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QWidget from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -27,3 +27,6 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) + self.centralWidget = QWidget() + self.centralWidget.setLayout(self.verticalLayout) + self.setWidget(self.centralWidget) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 933105b9..2bf01b48 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -25,8 +25,7 @@ class DetailsDialog(DetailsDialogBase): self.setWindowTitle(tr("Details")) self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) - self.setAllowedAreas(Qt.AllDockWidgetAreas) - self.splitter = QSplitter(Qt.Vertical, self) + self.splitter = QSplitter(Qt.Vertical) self.topFrame = QFrame() self.topFrame.setFrameShape(QFrame.StyledPanel) self.horizontalLayout = QGridLayout() @@ -96,7 +95,8 @@ class DetailsDialog(DetailsDialogBase): # due to the toolbar in the middle keeping the same width, # so resizing in the GridLayout's engine leads to not enough space # left for the panel on the right. - # This doesn't work as a QDockWidget however! + # This work as a QMainWindow, but doesn't work as a QDockWidget: + # resize can only grow. Might need some custom sizeHint somewhere... # self.horizontalLayout.setColumnMinimumWidth( # 0, self.selectedImageViewer.size().width()) # self.horizontalLayout.setColumnMinimumWidth( diff --git a/qt/preferences.py b/qt/preferences.py index c9691cca..39e55b1e 100644 --- a/qt/preferences.py +++ b/qt/preferences.py @@ -31,6 +31,8 @@ class Preferences(PreferencesBase): self.tableFontSize = get("TableFontSize", self.tableFontSize) self.reference_bold_font = get('ReferenceBoldFont', self.reference_bold_font) + self.details_dialog_titlebar_enabled = get('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) + self.details_dialog_vertical_titlebar = get('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) self.resultWindowIsMaximized = get( "ResultWindowIsMaximized", self.resultWindowIsMaximized ) @@ -67,6 +69,8 @@ class Preferences(PreferencesBase): self.tableFontSize = QApplication.font().pointSize() self.reference_bold_font = True + self.details_dialog_titlebar_enabled = True + self.details_dialog_vertical_titlebar = True self.resultWindowIsMaximized = False self.resultWindowRect = None self.directoriesWindowRect = None @@ -100,6 +104,8 @@ class Preferences(PreferencesBase): set_("TableFontSize", self.tableFontSize) set_('ReferenceBoldFont', self.reference_bold_font) + set_('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) + set_('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) self.set_rect("ResultWindowRect", self.resultWindowRect) self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index eb3462e3..2603eeb4 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -117,8 +117,21 @@ class PreferencesDialogBase(QDialog): self.widgetsVLayout.addLayout( horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None]) ) - self._setupAddCheckbox("reference_bold_font", tr("Bold font for reference.")) + self._setupAddCheckbox("reference_bold_font", + tr("Bold font for reference.")) self.widgetsVLayout.addWidget(self.reference_bold_font) + + self._setupAddCheckbox("details_dialog_titlebar_enabled", + tr("Details dialog displays a title bar and is dockable")) + self.widgetsVLayout.addWidget(self.details_dialog_titlebar_enabled) + self._setupAddCheckbox("details_dialog_vertical_titlebar", + tr("Details dialog displays a vertical title bar.")) + self.widgetsVLayout.addWidget(self.details_dialog_vertical_titlebar) + self.details_dialog_vertical_titlebar.setEnabled( + self.details_dialog_titlebar_enabled.isChecked()) + self.details_dialog_titlebar_enabled.stateChanged.connect( + self.details_dialog_vertical_titlebar.setEnabled) + self.languageLabel = QLabel(tr("Language:"), self) self.languageComboBox = QComboBox(self) for lang in self.supportedLanguages: @@ -190,6 +203,8 @@ class PreferencesDialogBase(QDialog): setchecked(self.ignoreHardlinkMatches, prefs.ignore_hardlink_matches) setchecked(self.debugModeBox, prefs.debug_mode) setchecked(self.reference_bold_font, prefs.reference_bold_font) + setchecked(self.details_dialog_titlebar_enabled , prefs.details_dialog_titlebar_enabled) + setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) self.customCommandEdit.setText(prefs.custom_command) self.fontSizeSpinBox.setValue(prefs.tableFontSize) @@ -210,6 +225,8 @@ class PreferencesDialogBase(QDialog): prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches) prefs.debug_mode = ischecked(self.debugModeBox) prefs.reference_bold_font = ischecked(self.reference_bold_font) + prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled) + prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar) prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() prefs.custom_command = str(self.customCommandEdit.text()) prefs.tableFontSize = self.fontSizeSpinBox.value() diff --git a/qt/se/details_dialog.py b/qt/se/details_dialog.py index 812c649f..0f922dc4 100644 --- a/qt/se/details_dialog.py +++ b/qt/se/details_dialog.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView +from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QWidget from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -27,3 +27,6 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) + self.centralWidget = QWidget() + self.centralWidget.setLayout(self.verticalLayout) + self.setWidget(self.centralWidget) From 95b8406c7b97aab170d127b466ff506b724def3c Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 15 Jul 2020 04:14:24 +0200 Subject: [PATCH 25/61] Fix scrollbar displayed while splitter maxed out * For some reason the table's height is a few pixel longer on Windows so we work around the issue by adding a small offset to the maximum height hint. * No idea about MacOS yet but this might need the same treatment. --- qt/pe/details_dialog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 07ecdfcb..7dcf0cd9 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import ( QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame) from hscommon.trans import trget +from hscommon.plat import ISWINDOWS from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from .image_viewer import ( @@ -117,7 +118,9 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setMaximumHeight( self.tableView.rowHeight(1) * self.tableModel.model.row_count() - + self.tableView.verticalHeader().sectionSize(0)) + + self.tableView.verticalHeader().sectionSize(0) + # Windows seems to add a few pixels more to the table somehow + + 5 if ISWINDOWS else 0) DetailsDialogBase.show(self) self._update() From 58c675d1fa90a7247233d9887a460cf5a8e4cbf5 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 15 Jul 2020 05:25:47 +0200 Subject: [PATCH 26/61] Add custom icons * Use custom icons on platforms which do not provide theme * Old zoom icons credits to "schollidesign" from icon pack Office and Entertainment (GPL licence). * Exchange icon credit to Jason Cho (Unknown license). * Use hack to resize viewers on first show() as well --- images/exchange.png | Bin 0 -> 797 bytes images/exchange_purple.png | Bin 0 -> 685 bytes images/old_zoom_best_fit.png | Bin 0 -> 12499 bytes images/old_zoom_in.png | Bin 0 -> 11564 bytes images/old_zoom_original.png | Bin 0 -> 12189 bytes images/old_zoom_out.png | Bin 0 -> 11522 bytes qt/dg.qrc | 5 +++++ qt/pe/details_dialog.py | 38 +++++++++++++++++++---------------- qt/pe/image_viewer.py | 19 ++++++++++++------ 9 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 images/exchange.png create mode 100644 images/exchange_purple.png create mode 100644 images/old_zoom_best_fit.png create mode 100644 images/old_zoom_in.png create mode 100644 images/old_zoom_original.png create mode 100644 images/old_zoom_out.png diff --git a/images/exchange.png b/images/exchange.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed902b78ec155cf08e2d1e82533ed00c8d2d367 GIT binary patch literal 797 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 literal 0 HcmV?d00001 diff --git a/images/old_zoom_in.png b/images/old_zoom_in.png new file mode 100644 index 0000000000000000000000000000000000000000..fbcbe2c169fff1a7a348ee6b4a15cd1c67251f41 GIT binary patch literal 11564 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 I-a7LC044CYApigX literal 0 HcmV?d00001 diff --git a/images/old_zoom_out.png b/images/old_zoom_out.png new file mode 100644 index 0000000000000000000000000000000000000000..f7e84c98e160ebe86263fc9658e808f43d7b854e GIT binary patch literal 11522 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 P%7DDAvP`A4Y4HC6euIu3 literal 0 HcmV?d00001 diff --git a/qt/dg.qrc b/qt/dg.qrc index 545a9806..941a7340 100644 --- a/qt/dg.qrc +++ b/qt/dg.qrc @@ -5,5 +5,10 @@ ../images/plus_8.png ../images/minus_8.png ../qtlib/images/search_clear_13.png + ../images/exchange_purple.png + ../images/old_zoom_in.png + ../images/old_zoom_out.png + ../images/old_zoom_original.png + ../images/old_zoom_best_fit.png diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 7dcf0cd9..15c16157 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -90,23 +90,7 @@ class DetailsDialog(DetailsDialogBase): # --- Override def resizeEvent(self, event): - # HACK referenceViewer might be 1 pixel shorter in width - # due to the toolbar in the middle keeping the same width, - # so resizing in the GridLayout's engine leads to not enough space - # left for the panel on the right. - # This ensures same size while shrinking at least: - self.horizontalLayout.setColumnMinimumWidth( - 0, self.selectedImageViewer.size().width()) - self.horizontalLayout.setColumnMinimumWidth( - 2, self.selectedImageViewer.size().width()) - # This works when expanding but it's ugly: - if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): - # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", - # f"""Before reference size: {self.referenceImageViewer.size()}""") - self.selectedImageViewer.resize(self.referenceImageViewer.size()) - # print(f"""After selected size: {self.selectedImageViewer.size()}\n""", - # f"""After reference size: {self.referenceImageViewer.size()}""") - + self.ensure_same_sizes() if self.vController is None or not self.vController.bestFit: return # Only update the scaled down pixmaps @@ -122,8 +106,28 @@ class DetailsDialog(DetailsDialogBase): # Windows seems to add a few pixels more to the table somehow + 5 if ISWINDOWS else 0) DetailsDialogBase.show(self) + self.ensure_same_sizes() self._update() + def ensure_same_sizes(self): + # HACK referenceViewer might be 1 pixel shorter in width + # due to the toolbar in the middle keeping the same width, + # so resizing in the GridLayout's engine leads to not enough space + # left for the panel on the right. + # This ensures same size while shrinking at least: + self.horizontalLayout.setColumnMinimumWidth( + 0, self.selectedImageViewer.size().width()) + self.horizontalLayout.setColumnMinimumWidth( + 2, self.selectedImageViewer.size().width()) + + # This works when expanding but it's ugly: + if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): + # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", + # f"""Before reference size: {self.referenceImageViewer.size()}""") + self.selectedImageViewer.resize(self.referenceImageViewer.size()) + # print(f"""After selected size: {self.selectedImageViewer.size()}\n""", + # f"""After reference size: {self.referenceImageViewer.size()}""") + # model --> view def refresh(self): DetailsDialogBase.refresh(self) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 54366e98..663b5cf7 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -10,6 +10,7 @@ from PyQt5.QtWidgets import ( QToolBar, QToolButton, QAction, QWidget, QScrollArea, QApplication, QAbstractScrollArea, QStyle) from hscommon.trans import trget +from hscommon.plat import ISLINUX tr = trget("ui") MAX_SCALE = 12.0 @@ -21,7 +22,7 @@ def createActions(actions, target): for name, shortcut, icon, desc, func in actions: action = QAction(target) if icon: - action.setIcon(QIcon.fromTheme(icon)) + action.setIcon(icon) if shortcut: action.setShortcut(shortcut) action.setText(desc) @@ -48,28 +49,32 @@ class ViewerToolBar(QToolBar): ( "actionZoomIn", QKeySequence.ZoomIn, - "zoom-in", + QIcon.fromTheme("zoom-in") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_in")), tr("Increase zoom"), controller.zoomIn, ), ( "actionZoomOut", QKeySequence.ZoomOut, - "zoom-out", + QIcon.fromTheme("zoom-out") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_out")), tr("Decrease zoom"), controller.zoomOut, ), ( "actionNormalSize", tr("Ctrl+/"), - "zoom-original", + QIcon.fromTheme("zoom-original") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_original")), tr("Normal size"), controller.zoomNormalSize, ), ( "actionBestFit", tr("Ctrl+*"), - "zoom-best-fit", + QIcon.fromTheme("zoom-best-fit") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_best_fit")), tr("Best fit"), controller.zoomBestFit, ) @@ -83,7 +88,9 @@ class ViewerToolBar(QToolBar): self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly) self.buttonImgSwap.setIcon( QIcon.fromTheme('view-refresh', - self.style().standardIcon(QStyle.SP_BrowserReload))) + self.style().standardIcon(QStyle.SP_BrowserReload)) + if ISLINUX + else QIcon(QPixmap(":/" + "exchange"))) self.buttonImgSwap.setText('Swap images') self.buttonImgSwap.setToolTip('Swap images') self.buttonImgSwap.pressed.connect(self.controller.swapImages) From 66127d025e9a497ee13126f955166946acdb35a8 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 15 Jul 2020 20:22:13 +0200 Subject: [PATCH 27/61] Add credit for icons used, upscale exchange icon * Jason Cho gave his express permission to use the icon (it was made 10 years ago and he doesn't have the source files anymore) * Used waifu2x to upscale the icon * Used GIMP to draw dark outline around the icon * Source files are included --- images/exchange.icns | Bin 0 -> 1774 bytes images/exchange.ico | Bin 0 -> 4286 bytes images/exchange_purple_upscaled.png | Bin 0 -> 9042 bytes images/exchange_purple_waifu_s4_tta8.png | Bin 0 -> 7118 bytes images/exchange_purple_waifu_s4_tta8.xcf | Bin 0 -> 18409 bytes images/exchange_waifu_s4_tta8.png | Bin 0 -> 5589 bytes qt/dg.qrc | 2 +- qtlib/about_box.py | 17 ++++++++++++++++- 8 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 images/exchange.icns create mode 100644 images/exchange.ico create mode 100644 images/exchange_purple_upscaled.png create mode 100644 images/exchange_purple_waifu_s4_tta8.png create mode 100644 images/exchange_purple_waifu_s4_tta8.xcf create mode 100644 images/exchange_waifu_s4_tta8.png diff --git a/images/exchange.icns b/images/exchange.icns new file mode 100644 index 0000000000000000000000000000000000000000..f93828b4d89a7f1935e1947eb9e01e03af844af7 GIT binary patch literal 1774 zcmeHEJ8s)R5FI-~;F3q+5eTPA>2gaRfj}yMAs~G)q9lJ}OQPL5Zd4lxz$5SoJVCH0 z2-u|@znNWGvZ)AgYh;1lc{A_Ln_cqo>)AJ=Pk#^ zHH`r6T!6UBmm`isLqk9-UsQ8!s zfARbO$0uJ-kBL6+(OXu=+tpQhLRW0RS5qxIiS?W##~W-ijjzrJPk X%r{Jrl!c^s`E?e!vA4e}ZcU#6q^z&A literal 0 HcmV?d00001 diff --git a/images/exchange.ico b/images/exchange.ico new file mode 100644 index 0000000000000000000000000000000000000000..cf5226c9dfb6ff168b604852af86c37e6b714d32 GIT binary patch literal 4286 zcmdT`y^hmB5MFcz1w|qXDx|oi^cy5)N`VlNq(3*-v zideocw`0wX*G95)I=Iuv?9R{k&5Rf6oO^}eBys%LeVse^&N=r1fXl4_`maBTy?yhD zM~8FX`~MQ_^|}P+FVR1B0S^=}q@M--hP^yT599@?Qz+y(C%^&V{5RhLIH^lb&L$ex z{!}sM-DC&gNr75%Tiw3<chXcT>2j2T1ZJy1jG6xPv}xslobA&mkr~ z6n#8X{LrX^PKO`+m@Wb8xy9+sc;QCHu@t6{$L98e3V_)r?8mYe@m`!WhGxst{oBe_#Rr(Fi9|6u~_2f?7 zY|N~EHQ?;R~fDE>MJCb(`3*J4@0F(DQrXT2X)^_$W9RE;__ZM{YKy3ZR zZGGdGz6MISxT2_M+t2lEd0#IVyV`%*o&9d-{TVnBXW-<&pT7Co=|?j=HRa;|QZMd) Y=kM=)?p}kCm|ck<@ZX_J*43(`?ijImo4z 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 literal 0 HcmV?d00001 diff --git a/images/exchange_purple_waifu_s4_tta8.png b/images/exchange_purple_waifu_s4_tta8.png new file mode 100644 index 0000000000000000000000000000000000000000..21bbcf531b967498e845d06f0034d2391d6746bd GIT binary patch literal 7118 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 literal 0 HcmV?d00001 diff --git a/qt/dg.qrc b/qt/dg.qrc index 941a7340..760f2a85 100644 --- a/qt/dg.qrc +++ b/qt/dg.qrc @@ -5,7 +5,7 @@ ../images/plus_8.png ../images/minus_8.png ../qtlib/images/search_clear_13.png - ../images/exchange_purple.png + ../images/exchange_purple_upscaled.png ../images/old_zoom_in.png ../images/old_zoom_out.png ../images/old_zoom_original.png diff --git a/qtlib/about_box.py b/qtlib/about_box.py index 88512666..99c3a059 100644 --- a/qtlib/about_box.py +++ b/qtlib/about_box.py @@ -42,7 +42,7 @@ class AboutBox(QDialog): self.setWindowTitle( tr("About {}").format(QCoreApplication.instance().applicationName()) ) - self.resize(400, 190) + self.resize(400, 290) sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -69,6 +69,21 @@ class AboutBox(QDialog): self.verticalLayout.addWidget(self.label_3) self.label_3.setText(tr("Licensed under GPLv3")) self.label = QLabel(self) + self.label_4 = QLabel(self) + self.label_4.setWordWrap(True) + self.label_4.setTextFormat(Qt.RichText) + self.label_4.setOpenExternalLinks(True) + self.label_4.setText(tr( + """Exchange icon + made by Jason Cho (used with permission). +
+Zoom In +Zoom Out +Zoomt Best Fit +Zoom Original + icons made by schollidesign + (licensed under GPL).""")) + self.verticalLayout.addWidget(self.label_4) font = QFont() font.setWeight(75) font.setBold(True) From 3c816b2f11ddc66a78cdc6327ee102df46d1a552 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 15 Jul 2020 21:43:01 +0200 Subject: [PATCH 28/61] Fix computing and setting offset to 0 for tableview --- qt/details_table.py | 3 +-- qt/pe/details_dialog.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/qt/details_table.py b/qt/details_table.py index f02e479b..854a1595 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QAbstractTableModel -from PyQt5.QtWidgets import QHeaderView, QTableView, QAbstractItemView +from PyQt5.QtWidgets import QHeaderView, QTableView from hscommon.trans import trget @@ -55,7 +55,6 @@ class DetailsTable(QTableView): self.setShowGrid(False) self.setWordWrap(False) - def setModel(self, model): QTableView.setModel(self, model) # The model needs to be set to set header stuff diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index dde2cfc7..43793bfb 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -105,7 +105,7 @@ class DetailsDialog(DetailsDialogBase): * self.tableModel.model.row_count() + self.tableView.verticalHeader().sectionSize(0) # Windows seems to add a few pixels more to the table somehow - + 5 if ISWINDOWS else 0) + + (5 if ISWINDOWS else 0)) DetailsDialogBase.show(self) self.ensure_same_sizes() self._update() From 75621cc816120597f493e0debc6d88e2e0bbd30a Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 15 Jul 2020 22:04:19 +0200 Subject: [PATCH 29/61] Prevent Windows from floating if no decoration * Windows users cannot move a window which has no native decorations. Toggling a dock widget's titlebar off also removes native decorations on a floating window. Until we implement a replacement titlebar by overriding paintEvents, simply force the floating window to go back to docked state after we toggled the titlebar off. --- qt/details_dialog.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 56555c30..75b4abb1 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -10,11 +10,13 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QDockWidget, QWidget from .details_table import DetailsModel +from hscommon.plat import ISLINUX class DetailsDialog(QDockWidget): def __init__(self, parent, app, **kwargs): super().__init__(parent, Qt.Tool, **kwargs) + self.parent = parent self.app = app self.model = app.model.details_panel self.setAllowedAreas(Qt.AllDockWidgetAreas) @@ -44,6 +46,10 @@ class DetailsDialog(QDockWidget): if not self.app.prefs.details_dialog_titlebar_enabled \ and not self.titleBarWidget(): self.setTitleBarWidget(QWidget()) + # Windows (and MacOS?) users cannot move a floating window which + # has not native decoration so we force it to dock for now + if not ISLINUX: + self.setFloating(False) elif self.titleBarWidget() is not None: # resets to the default title bar self.setTitleBarWidget(None) From 9168d72f38faaf0a12230cd544f14190cd29fca4 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 15 Jul 2020 22:47:32 +0200 Subject: [PATCH 30/61] Update preferences on show(), not in constructor * If the dialog window shouldn't have a titlebar during construction, update accordingly only when showing to fix Windows displaying a window without titlebar on first show * Only save geometry if the window is floating. Otherwise geometry while docked is saved whih gives weird results on subsequent starts, since it may be floating by default anyway (at least on Linux where titlebar being disabled is allowed while floating) * Vertical title bar doesn't seem to work on Windows, add note in preferences dialog --- qt/details_dialog.py | 4 ++-- qt/preferences_dialog.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 75b4abb1..7b3f07d5 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -21,7 +21,6 @@ class DetailsDialog(QDockWidget): self.model = app.model.details_panel self.setAllowedAreas(Qt.AllDockWidgetAreas) self._setupUi() - self.update_options() # To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog # has been shown. If it has, we know that our geometry should be saved. self._shown_once = False @@ -39,6 +38,7 @@ class DetailsDialog(QDockWidget): def show(self): self._shown_once = True super().show() + self.update_options() def update_options(self): # This disables the title bar (if we had not set one before already) @@ -62,7 +62,7 @@ class DetailsDialog(QDockWidget): # --- Events def appWillSavePrefs(self): - if self._shown_once: + if self._shown_once and self.isFloating(): self.app.prefs.saveGeometry("DetailsWindowRect", self) # --- model --> view diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 2603eeb4..95eb1b67 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -118,14 +118,14 @@ class PreferencesDialogBase(QDialog): horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None]) ) self._setupAddCheckbox("reference_bold_font", - tr("Bold font for reference.")) + tr("Bold font for reference")) self.widgetsVLayout.addWidget(self.reference_bold_font) self._setupAddCheckbox("details_dialog_titlebar_enabled", tr("Details dialog displays a title bar and is dockable")) self.widgetsVLayout.addWidget(self.details_dialog_titlebar_enabled) self._setupAddCheckbox("details_dialog_vertical_titlebar", - tr("Details dialog displays a vertical title bar.")) + tr("Details dialog displays a vertical title bar (Linux only)")) self.widgetsVLayout.addWidget(self.details_dialog_vertical_titlebar) self.details_dialog_vertical_titlebar.setEnabled( self.details_dialog_titlebar_enabled.isChecked()) From 733b3b0ed4fbd6de908c968402af03879df3336f Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 16 Jul 2020 01:31:24 +0200 Subject: [PATCH 31/61] Prevent zoom for images of differing dimensions * If images are not the same size, prevent zooming features from being used by disabling the normal size button, only enable swap --- qt/pe/image_viewer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 663b5cf7..1c4d38c0 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -178,7 +178,10 @@ class BaseController(QObject): else: self.referencePixmap = QPixmap(str(ref.path)) self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) - self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if ref.dimensions != dupe.dimensions: + self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) + else: + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) self.updateBothImages(same_group) self.centerViews(same_group and self.referencePixmap.isNull()) From ac941037ff51158b64daa65c244df26346af10cf Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 16 Jul 2020 22:21:24 +0200 Subject: [PATCH 32/61] Fix resize of top frame not updating scaled pixmap * Also limit viewing features such as zoom levels when files have different dimensions * GraphicsViewImageViewer is still a bit buggy: the scrollbars are toggled on when the pixmap is null in the reference viewer (we do not use that class right anyway) --- qt/pe/details_dialog.py | 17 +++++-- qt/pe/image_viewer.py | 99 ++++++++++++++++++++++++++++++++++------- 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 43793bfb..d96dba17 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,10 +4,10 @@ # 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 Qt, QSize +from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame) - +from PyQt5.QtGui import QResizeEvent from hscommon.trans import trget from hscommon.plat import ISWINDOWS from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -27,7 +27,7 @@ class DetailsDialog(DetailsDialogBase): self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) self.splitter = QSplitter(Qt.Vertical) - self.topFrame = QFrame() + self.topFrame = EmittingFrame() self.topFrame.setFrameShape(QFrame.StyledPanel) self.horizontalLayout = QGridLayout() # Minimum width for the toolbar in the middle: @@ -76,6 +76,8 @@ class DetailsDialog(DetailsDialogBase): # self.setCentralWidget(self.splitter) # only as QMainWindow self.setWidget(self.splitter) # only as QDockWidget + self.topFrame.resized.connect(self.resizeEvent) + def _update(self): if self.vController is None: # Not yet constructed! return @@ -90,6 +92,7 @@ class DetailsDialog(DetailsDialogBase): self.vController.updateView(ref, dupe, group) # --- Override + @pyqtSlot(QResizeEvent) def resizeEvent(self, event): self.ensure_same_sizes() if self.vController is None or not self.vController.bestFit: @@ -136,3 +139,11 @@ class DetailsDialog(DetailsDialogBase): DetailsDialogBase.refresh(self) if self.isVisible(): self._update() + + +class EmittingFrame(QFrame): + """Emits a signal whenever is resized""" + resized = pyqtSignal(QResizeEvent) + + def resizeEvent(self, event): + self.resized.emit(event) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 1c4d38c0..08a70db8 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -149,6 +149,7 @@ class BaseController(QObject): self.bestFit = True self.parent = parent # To change buttons' states self.cached_group = None + self.same_dimensions = True def setupViewers(self, selectedViewer, referenceViewer): self.selectedViewer = selectedViewer @@ -163,6 +164,8 @@ class BaseController(QObject): def updateView(self, ref, dupe, group): # To keep current scale accross dupes from the same group + previous_same_dimensions = self.same_dimensions + self.same_dimensions = True same_group = True if group != self.cached_group: same_group = False @@ -171,6 +174,7 @@ class BaseController(QObject): self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: # currently selected file is the actual reference file + # self.same_dimensions = False self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) @@ -179,9 +183,9 @@ class BaseController(QObject): self.referencePixmap = QPixmap(str(ref.path)) self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) if ref.dimensions != dupe.dimensions: - self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) - else: - self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + self.same_dimensions = False + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + self.updateButtonsAsPerDimensions(previous_same_dimensions) self.updateBothImages(same_group) self.centerViews(same_group and self.referencePixmap.isNull()) @@ -217,11 +221,11 @@ class BaseController(QObject): # zoomed in state, expand # only if not same_group, we need full update scaledpixmap = pixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + target_size, Qt.KeepAspectRatioByExpanding, Qt.FastTransformation) else: # best fit, keep ratio always scaledpixmap = pixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + target_size, Qt.KeepAspectRatio, Qt.FastTransformation) viewer.setImage(scaledpixmap) return target_size @@ -304,6 +308,22 @@ class BaseController(QObject): self.parent.verticalToolBar.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0) self.parent.verticalToolBar.buttonBestFit.setEnabled(self.bestFit is False) + def updateButtonsAsPerDimensions(self, previous_same_dimensions): + if not self.same_dimensions: + self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) + if not self.bestFit: + self.zoomBestFit() + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if not self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + return + if not self.bestFit and not previous_same_dimensions: + self.zoomBestFit() + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) + @pyqtSlot() def zoomBestFit(self): """Setup before scaling to bestfit""" @@ -330,6 +350,7 @@ class BaseController(QObject): self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) self.parent.verticalToolBar.buttonBestFit.setEnabled(False) + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) def setBestFit(self, value): self.bestFit = value @@ -349,8 +370,12 @@ class BaseController(QObject): self.selectedViewer.scaleToNormalSize() self.referenceViewer.scaleToNormalSize() - self.parent.verticalToolBar.buttonZoomIn.setEnabled(True) - self.parent.verticalToolBar.buttonZoomOut.setEnabled(True) + if self.same_dimensions: + self.parent.verticalToolBar.buttonZoomIn.setEnabled(True) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(True) + else: + # we can't allow swapping pixmaps of different dimensions + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) self.parent.verticalToolBar.buttonBestFit.setEnabled(True) @@ -371,8 +396,16 @@ class QWidgetController(BaseController): def __init__(self, parent): super().__init__(parent) + def _updateImage(self, *args): + ret = super()._updateImage(*args) + # Fix alignment when resizing window + self.centerViews() + return ret + @pyqtSlot(QPointF) def onDraggedMouse(self, delta): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer: self.selectedViewer.onDraggedMouse(delta) else: @@ -410,8 +443,14 @@ class ScrollAreaController(BaseController): self.selectedViewer.ignore_signal = True self.referenceViewer.ignore_signal = True - self.selectedViewer.onDraggedMouse(delta) - self.referenceViewer.onDraggedMouse(delta) + if self.same_dimensions: + self.selectedViewer.onDraggedMouse(delta) + self.referenceViewer.onDraggedMouse(delta) + else: + if self.sender() is self.selectedViewer: + self.selectedViewer.onDraggedMouse(delta) + else: + self.referenceViewer.onDraggedMouse(delta) self.selectedViewer.ignore_signal = False self.referenceViewer.ignore_signal = False @@ -432,6 +471,8 @@ class ScrollAreaController(BaseController): @pyqtSlot(int) def onVScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._verticalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._verticalScrollBar.setValue(value) @@ -441,6 +482,8 @@ class ScrollAreaController(BaseController): @pyqtSlot(int) def onHScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._horizontalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._horizontalScrollBar.setValue(value) @@ -458,6 +501,8 @@ class ScrollAreaController(BaseController): def zoomBestFit(self): # Disable scrollbars to avoid GridLayout size rounding glitch super().zoomBestFit() + if self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) self.selectedViewer.toggleScrollBars() self.referenceViewer.toggleScrollBars() @@ -494,6 +539,8 @@ class GraphicsViewController(BaseController): @pyqtSlot(int) def onVScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._verticalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._verticalScrollBar.setValue(value) @@ -503,6 +550,8 @@ class GraphicsViewController(BaseController): @pyqtSlot(int) def onHScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._horizontalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._horizontalScrollBar.setValue(value) @@ -528,9 +577,16 @@ class GraphicsViewController(BaseController): self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if not self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + # else: + # self.referenceViewer.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + # self.referenceViewer.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) def updateView(self, ref, dupe, group): # Keep current scale accross dupes from the same group + previous_same_dimensions = self.same_dimensions + self.same_dimensions = True same_group = True if group != self.cached_group: same_group = False @@ -539,16 +595,19 @@ class GraphicsViewController(BaseController): self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: # currently selected file is the actual reference file + self.same_dimensions = False 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) + if ref.dimensions != dupe.dimensions: + self.same_dimensions = False self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) - - self.selectedViewer.setImage(self.selectedPixmap) - self.referenceViewer.setImage(self.referencePixmap) + # self.selectedViewer.setImage(self.selectedPixmap) + # self.referenceViewer.setImage(self.referencePixmap) + self.updateButtonsAsPerDimensions(previous_same_dimensions) self.updateBothImages(same_group) def updateBothImages(self, same_group=False): @@ -567,7 +626,9 @@ class GraphicsViewController(BaseController): def _updateFitImage(self, pixmap, viewer): # If not same_group, we need full update""" + viewer.setImage(pixmap) if pixmap.isNull(): + # viewer._item = None return if viewer.bestFit: viewer.fitScale() @@ -727,7 +788,7 @@ class QWidgetImageViewer(QWidget): self.setMouseTracking(False) def wheelEvent(self, event): - if self.bestFit or not self.isEnabled(): + if self.bestFit or not self.controller.same_dimensions or not self.isEnabled(): event.ignore() return @@ -920,7 +981,7 @@ class ScrollAreaImageViewer(QScrollArea): super().mouseReleaseEvent(event) def wheelEvent(self, event): - if self.bestFit: + if self.bestFit or not self.controller.same_dimensions: event.ignore() return oldScale = self.current_scale @@ -1044,7 +1105,7 @@ class ScrollAreaImageViewer(QScrollArea): class GraphicsViewViewer(QGraphicsView): - """Re-Implementation using a more full fledged class.""" + """Re-Implementation a full-fledged GraphicsView but is a bit buggy.""" mouseDragged = pyqtSignal() mouseWheeled = pyqtSignal(float, QPointF) @@ -1173,7 +1234,7 @@ class GraphicsViewViewer(QGraphicsView): self._centerPoint = self.mapToScene(self.rect().center()) def wheelEvent(self, event): - if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE: + if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE or not self.controller.same_dimensions: event.ignore() return pointBeforeScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos()))) @@ -1196,6 +1257,10 @@ class GraphicsViewViewer(QGraphicsView): self.other_viewer.ignore_signal = False def setImage(self, pixmap): + if pixmap.isNull(): + self.ignore_signal = True + elif self.ignore_signal: + self.ignore_signal = False self._pixmap = pixmap self._item.setPixmap(pixmap) self.translate(1, 1) @@ -1251,7 +1316,7 @@ class GraphicsViewViewer(QGraphicsView): """Called when the pixmap is set back to original size.""" self.bestFit = False self.scaleAt(1.0) - self.toggleScrollBars() + self.toggleScrollBars(True) self.update() def adjustScrollBarsScaled(self, delta): From 6213d506702fb3fbf5fdf9e5365121457b6bff28 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 16 Jul 2020 22:31:54 +0200 Subject: [PATCH 33/61] Squashed commit of the following: commit ac941037ff51158b64daa65c244df26346af10cf Author: glubsy Date: Thu Jul 16 22:21:24 2020 +0200 Fix resize of top frame not updating scaled pixmap * Also limit viewing features such as zoom levels when files have different dimensions * GraphicsViewImageViewer is still a bit buggy: the scrollbars are toggled on when the pixmap is null in the reference viewer (we do not use that class right anyway) commit 733b3b0ed4fbd6de908c968402af03879df3336f Author: glubsy Date: Thu Jul 16 01:31:24 2020 +0200 Prevent zoom for images of differing dimensions * If images are not the same size, prevent zooming features from being used by disabling the normal size button, only enable swap commit 9168d72f38faaf0a12230cd544f14190cd29fca4 Author: glubsy Date: Wed Jul 15 22:47:32 2020 +0200 Update preferences on show(), not in constructor * If the dialog window shouldn't have a titlebar during construction, update accordingly only when showing to fix Windows displaying a window without titlebar on first show * Only save geometry if the window is floating. Otherwise geometry while docked is saved whih gives weird results on subsequent starts, since it may be floating by default anyway (at least on Linux where titlebar being disabled is allowed while floating) * Vertical title bar doesn't seem to work on Windows, add note in preferences dialog commit 75621cc816120597f493e0debc6d88e2e0bbd30a Author: glubsy Date: Wed Jul 15 22:04:19 2020 +0200 Prevent Windows from floating if no decoration * Windows users cannot move a window which has no native decorations. Toggling a dock widget's titlebar off also removes native decorations on a floating window. Until we implement a replacement titlebar by overriding paintEvents, simply force the floating window to go back to docked state after we toggled the titlebar off. commit 3c816b2f11ddc66a78cdc6327ee102df46d1a552 Author: glubsy Date: Wed Jul 15 21:43:01 2020 +0200 Fix computing and setting offset to 0 for tableview commit 85d6e05cd406b999e8f6ae421a9746a0c9f146bb Merge: 66127d02 3eddeb6a Author: glubsy Date: Wed Jul 15 21:25:44 2020 +0200 Merge branch 'dockable_windows' into details_dialog_improvements_dev commit 66127d025e9a497ee13126f955166946acdb35a8 Author: glubsy Date: Wed Jul 15 20:22:13 2020 +0200 Add credit for icons used, upscale exchange icon * Jason Cho gave his express permission to use the icon (it was made 10 years ago and he doesn't have the source files anymore) * Used waifu2x to upscale the icon * Used GIMP to draw dark outline around the icon * Source files are included commit 58c675d1fa90a7247233d9887a460cf5a8e4cbf5 Author: glubsy Date: Wed Jul 15 05:25:47 2020 +0200 Add custom icons * Use custom icons on platforms which do not provide theme * Old zoom icons credits to "schollidesign" from icon pack Office and Entertainment (GPL licence). * Exchange icon credit to Jason Cho (Unknown license). * Use hack to resize viewers on first show() as well commit 95b8406c7b97aab170d127b466ff506b724def3c Author: glubsy Date: Wed Jul 15 04:14:24 2020 +0200 Fix scrollbar displayed while splitter maxed out * For some reason the table's height is a few pixel longer on Windows so we work around the issue by adding a small offset to the maximum height hint. * No idea about MacOS yet but this might need the same treatment. commit 3eddeb6aebc99126e62eb05af60333ba3bd22e82 Author: glubsy Date: Tue Jul 14 17:37:48 2020 +0200 Fix ME/SE details dialogs, add preferences * Fix ME and SE versions of details dialog not displaying their content properly after change to QDockWidget * Add option to toggle titlebar and orientation of titlebar in preferences dialog * Fix setting layout on PE details dialog window while layout already set, by removing the self (parent) reference in constructing the QSplitter commit 56912a71084415eac2f447650279d833d9857686 Author: glubsy Date: Mon Jul 13 05:06:04 2020 +0200 Make details dialog dockable --- images/exchange.icns | Bin 0 -> 1774 bytes images/exchange.ico | Bin 0 -> 4286 bytes images/exchange.png | Bin 0 -> 797 bytes images/exchange_purple.png | Bin 0 -> 685 bytes images/exchange_purple_upscaled.png | Bin 0 -> 9042 bytes images/exchange_purple_waifu_s4_tta8.png | Bin 0 -> 7118 bytes images/exchange_purple_waifu_s4_tta8.xcf | Bin 0 -> 18409 bytes images/exchange_waifu_s4_tta8.png | Bin 0 -> 5589 bytes images/old_zoom_best_fit.png | Bin 0 -> 12499 bytes images/old_zoom_in.png | Bin 0 -> 11564 bytes images/old_zoom_original.png | Bin 0 -> 12189 bytes images/old_zoom_out.png | Bin 0 -> 11522 bytes qt/app.py | 9 +- qt/details_dialog.py | 30 +++++- qt/dg.qrc | 5 + qt/me/details_dialog.py | 5 +- qt/pe/details_dialog.py | 67 ++++++++----- qt/pe/image_viewer.py | 115 +++++++++++++++++++---- qt/preferences.py | 6 ++ qt/preferences_dialog.py | 19 +++- qt/se/details_dialog.py | 5 +- qtlib/about_box.py | 17 +++- 22 files changed, 226 insertions(+), 52 deletions(-) create mode 100644 images/exchange.icns create mode 100644 images/exchange.ico create mode 100644 images/exchange.png create mode 100644 images/exchange_purple.png create mode 100644 images/exchange_purple_upscaled.png create mode 100644 images/exchange_purple_waifu_s4_tta8.png create mode 100644 images/exchange_purple_waifu_s4_tta8.xcf create mode 100644 images/exchange_waifu_s4_tta8.png create mode 100644 images/old_zoom_best_fit.png create mode 100644 images/old_zoom_in.png create mode 100644 images/old_zoom_original.png create mode 100644 images/old_zoom_out.png diff --git a/images/exchange.icns b/images/exchange.icns new file mode 100644 index 0000000000000000000000000000000000000000..f93828b4d89a7f1935e1947eb9e01e03af844af7 GIT binary patch literal 1774 zcmeHEJ8s)R5FI-~;F3q+5eTPA>2gaRfj}yMAs~G)q9lJ}OQPL5Zd4lxz$5SoJVCH0 z2-u|@znNWGvZ)AgYh;1lc{A_Ln_cqo>)AJ=Pk#^ zHH`r6T!6UBmm`isLqk9-UsQ8!s zfARbO$0uJ-kBL6+(OXu=+tpQhLRW0RS5qxIiS?W##~W-ijjzrJPk X%r{Jrl!c^s`E?e!vA4e}ZcU#6q^z&A literal 0 HcmV?d00001 diff --git a/images/exchange.ico b/images/exchange.ico new file mode 100644 index 0000000000000000000000000000000000000000..cf5226c9dfb6ff168b604852af86c37e6b714d32 GIT binary patch literal 4286 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 literal 0 HcmV?d00001 diff --git a/images/exchange_purple_waifu_s4_tta8.png b/images/exchange_purple_waifu_s4_tta8.png new file mode 100644 index 0000000000000000000000000000000000000000..21bbcf531b967498e845d06f0034d2391d6746bd GIT binary patch literal 7118 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 literal 0 HcmV?d00001 diff --git a/images/old_zoom_best_fit.png b/images/old_zoom_best_fit.png new file mode 100644 index 0000000000000000000000000000000000000000..444d4dcf1590ddda34d50cea3e6b11ee2bb02b6d GIT binary patch literal 12499 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 literal 0 HcmV?d00001 diff --git a/images/old_zoom_in.png b/images/old_zoom_in.png new file mode 100644 index 0000000000000000000000000000000000000000..fbcbe2c169fff1a7a348ee6b4a15cd1c67251f41 GIT binary patch literal 11564 zcmYLP1yodRw7oM!hqQEecS@&przjvLJxKSUgb2t_iIj9mcgFw%N~e^-ARt}RAn>kl zy|v!VojYsp)V<&Loqf*Pd&lVMsNv&K;Q#=Dul`io0DMOLdthOJ`#xElVekp!WuT@A zRE^Q>fG;p!YN{y%5C87Doh8ZO5p4KVb1wj30pDfWf#|VpU4aKNz16itPTu3oQ!i|iRZKW!?=XNr*IXZ@Zyz4_R#43V#PK$ zgDXCYrcok>n~9m5^%BCh<{i83#OWC!bvBaSH6&09%s1*tYwF|oK9m^7K!IlgSmL0#0GGyA;$jtQc$@-Uzv#r+*p#; z?dnxTp|Im(j3`G~IC6~`xPK5{!9+a*tkA7_?XGr8ay|?Us2Uj?D}0pu)SAzMLmY#) zxU^Io_7K|8*cgxY&J+k1C&cP7gd$fzpNIj}kYIZG*3h4O=fC2F$B-lG!fWS&3Ru@q zkA@}HgwmCDbqOBPNFWN-5||R7k}eWFprby>Gr$Yc5Z&?mm*{{TRKQp+bb!(k_0wxy z#oXL{a@D@xeB-qw$9t%XzC7pXEgry@j9tg0^l`Ie+@YTGact4H&-9MS;^JbB*OpF0 zLj&@d?=^5wi%fuq%X?r0^!TTykSjq%P*Elja~f6ceO)_ImoD^rYDx4XoE^X&4HIr)2*d^GIscN(S>p59s>3}VR$+cJeM z?)u!HPEpk((CT4mLSGz7I-yB98F&kjM`bZn#lJjS>6~wLNt;yNMuYn>5MH@p1GcvJ zuE03#69G`~I6jae>S8UHdFmY$7ABYU-EPuFZ=m+bpVWgMZ+-bUP z5$upFz@4@VYlo-*)Whf3b+<1=Hf`0zfmr>ohGzv1^_=!%!=Cj#>=hjxDk zqWy*Xf2V@T$C8&NK%J7vH2h73f^is%k5mGz>lbQ5?*hT7h&jExyQ2VaY`-5`>Zu>@ zw#4w*PAfy2hd*8fX3@&#+?LL*(_Ar(&(OzQ2j1rUz$U+@*m3QNd_I*JF>7|kG08HQ z(US4+d~p>ldlJT!h|E)pJ|3Xh^bLhA5hw!q?Fe?=61C^TD$AyDGJT_yItLG8hWm9rV8{Tcf5O#E|rpIDgxM& zdnOCiYC~>)!Ns{}b$bcM>M%PM&|?jMgy@DIXN)@h^k0vMw=XNM`uY0)jFe&C zh?6_o6K-)N&M|l%8oX=tz!z&iMuP4m+wSFCIc&Ps7(`=|-0v)w&Z@{VCbOs&2;f zc;?3soOXtEt^CGDvZW?B;Y9%}Tr?;_7!xG7H82b!Kmb&=A4<*4&Eax)7Zfm$mk9D{RT88B1TF@5p;R=T}vR#m=!L4_o=;lFh|a=PXP|bO!4z z;gE3(|2cW*npSQi*(qB3LZ0s0FWx_VuU^f430d3r9-AFo)jRm=H#e$Ow3I~tq_n)8 z&&;f3U^=z53S&el#;jNpSTY!Ft60PUY zOCu7dlM46w$!o7&EB?!t2bz3XSF6h~%6s0Vc#T%WGiv z;XFiaFa<_i`+0c~BhVZ|*bDPK>MCwJt&{A>05)}m-P?WnsP=~c z6}@@g-m%2kGN2ae(1X;O3{sbNGfXx)sI@i>u-`h@Eb%yn6}5{B{a5sM`;PK*T z*((_nt;;9qxx(pqmVD$`jBp7WMP6?|KNx}*X}{F$@i2P5m6MZx24G>lHFZ9uE7s18 z8s_h|q|gir2?@YhwaU1uy>3;?wHPcv4GPrB7C$hqH#Y&S-JhplW_Xok9bV2$D;r$% zdvH=SG=aOnr{iJH1~7S&yK$hj!HmbO zJ5;8^L^NAYLgEv-n%s|UWVdVpfV0kk_DQ&JRir%69g{HvhMa08^C8dvo#P^=&PFKD z6!8>Indv_)$Ye8PRI*K{Y|ajvQm)O~ zfO>0!{!rk4_8i`W4wo_(OxG!iKP19Pmi&3Us{L)l=*ac=Oc}Fb$<-x*U?FJ?1)p;0snKS)n&O0t|n;VwfO>1QWNkz~PiqNlDi0_s%h% zAG*3^iPj7`)Q-Qfov9f6m_6Hkq?=>XX?VF_ZD~0t(cV;H;qAyc>+_u8Ee??Q&YDed z*Y_8|g5b07*1*lKNYokHZ^<3ot07LO%5Msn26f7Eg$>)84D1zQAs zOp>w=-Pn0=htYB2055FrKTZLdDoeR}QnpF5D;nZE%VcDUR?unovKrqg=giH}A)RH% zL_&b!Py`nkq6`t>C^FI|6y)VW)IQKaW_r`cVgBAiUl8;F9EOY}e}Sc*grrd7c`VGP ze!OS;?CVymu*+)QQl+{~ezR-nrdAJ%aW8H!r}cO9UZ{+%<<%(@f+U84EQW#d5s6!Z zorKm|r7FI{o}V=!0a@EXv!-~u5oH`Jwg1CteZQ^yvCA{QG$j!#!^ZZx!|m0fb4K`Sw#Bw?b}~L9<+D`>LhpfIHnc(z0*~(XPYHP zqJ)ZA^f((%H2MKTq!W{GC9(t`4M><9};ms>4Y#3V>PNk`bxvIqreu^3PS_tneN zNi~T{6y<4ZJMxGhNoBH$Hjfd zLAP$5Lr_S^wY{$p)^raZ7k!yr{aiA6CfOX9PxjERLLagy4F*Dc`B2A$o?HC2)Y z3M3l54-bdH#}u%LfC=U(pemgQXu;7dcjV&c?xBF$@>FLy?j6!KT$VQ``xt+&Svq*P z&9_R4zj?0MOTH9a!`m_jALudCLI^$@lB7>i=#Qq*G5SrS2UYZue}&2g%%m#|RSgL? z-M6eG=Voz$gF8;wSyvh$pG085x_OClxUP2PB*|L9Oe_yVh=*|y*kPsn9zLe>rAcS} zUL`){DRIvX=Y8|py5k5(b=@QIxXxb&})nbE1OhVq_@#yB7B-O(_QxtD&k) zACL5FI^y5a@$Uo6vfEfC7!6)V!6gFljT$!YV0&I*f=s=j(eLREYqEMCMuiEtvx`go z&iit9DWGaf_~q>EEGjE@ONB#gS(e;SE;A)E3|_+1e5y76zXGIg{e z3DPlz$XY>FGGq@Ypdn8&r`)N(zjqsM$M;y1(+PG;*5~)*HBJpWJ?IVA0f&XJ+`;aGZ7fEuXikp zV}sz@gKuBMha@;m%lk`v2n(|e#C#9EiYdd5V9@mr39QAN{<%SBLGZG*5D~1d55mE{ucBV_-5(-sZUJlmfBMXMMXi`|1URY$$9a8Qd zC^1k>otKrGo_7jd7`@dcyQ307#t|dp`R*ITBxfyUY!gZ{WaJ^|F9rjX?wAF_D6m*! z1QMS4U}rvA-%hYUEd-Z!5=)P(SV2>h?iA1?P?p~`O@HWPW8{>0CdLjh>BfRdYQFB2sGc>evuZTvi zco8tF^N|B;X!xY;{CrBIyE+vS(AU;xn_Xz<50nkR#V6HX8lNrV3#>1BW z_N`|x>i{IC)P#Z28fQJl69Mm)TvJ}hS&I-L6GlU22@(Pk!pA$Eowii`I#>`*HuW$O zPM4%efi-71ycpwiV8PCI&jlO^QmQ)D?yAn6<}~!DEwB(nQd4qLql%>i4$ZD z_a;CJP&D#T;I)BWaX1CrYLVF^&)_*G!IR`AskwasjA{c*dDUc%!uQK1U25#gw&b6>(ZkM)cQgMe`NCC1s26f@A67*Ncv0 zJrqfCjN)VAoNZ$c7#Q49wrwn^tgYvn&Re)gj%;Y==BirU{M<9=%n$_CqJ>27JO|^b zgRmA`vaD2@Lq3GT2G3{rFrEl_v8Bgbl+O`h62entM*(9d-omp^lKK`cNI2#tsW(Ps zOxob0uVP5lsf|nV#DqWB%!8keh!k*A7y4|=EVyhfu95CKX0lJaeJR*B9F^x;8-oEP zUQGyx8Qc%QX&!m`E@CnmDD*NH{if7%!95@^p%D6FRr%e!t2nl@_^9Pq))Sqnibrpp zy7|!*dqNhlwAfX*7&8rN`d1VffjCOT(&-T`4#1mqpfx6sBgYHm63{aHBfLK{We#t0 zc4&Rre4WDS@Dz>M2G~+gMa!bbh#TYh)EXN0= z9eK6n*>ABla=i@~y#I0&IPWk{r?4P={Fh2jo*H?&5_Gf~o>!xm<1;3SYR$S2k>Lk) zzMN$21{}z01_mWMR0t?r|MXhl%2GTCT#8RBnb7;ZO)Q@Vjo(uHo5_3Uca<`I9%unet$x9QXzvI>2$I0ZP#p$XG z8%(#8pT_$VTM`5vFc5(`GT_Bxyv{?LaOTjF0^(S27OTXS^-05DuX}qqj$b_5`F+1V zBVCK1E0aZ~r`a(p6gD7Xk7u;%DnVbrV^)~3uP^!|F`zz%6k9m9df;x$auP>I&HEGwT>(;$+dN4T zKY5>k_tkM51J#S6`+2;2>FLdn<>kum=I5DWZbRD2!2YCC7DyL;+kCA!vtxjCO@jZ{ z@|ij^W3ts{5%yij9p0c=Zfb50(A~2R0!a{^Ei+YHAzLN9k~lBb^u_ha6v8bdcos)K zJF;i~RcENB%nHZoub2kz?C-_^GF^DsMoc~ba&JoCG#(`1zS|jKB5@_Kop{8MxQn_@w3+GJ9Azic`sceut=xVzu5N$3+Ugf`_?5C zJL{Cnn%U2?z{|GaAeLU?p#Cd`8^``jMemg4&{R5&ru}QMeP53knZeoA@9T1H(KIcc zjurBYunib|($0*3)599y)v@A{g+&5@q;cd-Rt|gD`VE2M*cSdtZeFC)oA1)(p z;}KG12l?ikN{pW6#{Q~-8`YJS+K!h_mW zDNp(@&aP0>Rif=G3oh$pdU9EuRsDC}x~7orDR`+oKEJpk&Ye1DQjWOnbxMv@vlB{} z$0A}p1u0&*LX`i()g zT1%{J!Z~*Iq^O3b%k1j5Am?X3bxQ}oLbkb1u1?k!|8l>7ZSej<5XL}=)d0++1GKcX z`8UQ%EMU7QNE2w03M1#$8$4t;G{3D~@AOjjueD1_lbs6i3~msqR=|lfnh^YPyBVH) zZFc#_)#L<Nv0q-!|fkd?muw z5&q{7 zOa$istNhd@`xq*l}-^{B(*oun`&^% ziAKG|QzTU}Loj=d4R(rqX-8_kvF!EG(}YVJ*#=mjFNl_Um%BBZ@qmrWe=OK32HR_Y zp11q%jOglXguO}rG$5^_1v1n86nOV!NXcjd`hg1bdI13eLFgJHD?%2l>;YET7 z>v=Cx`n?zP zy|9_gS9w}w05-{B(Eyjqe#$}&U$75eA6mGYV<$j(q2^Ck>~*3myUTi)B~=M2?tGWy zlkZRKkc^g5VFSmBPdXTQa;?<(^kB?iB3~jImOEkRl?Nf^K7B{jrejgVe7JqOIZwca z6T7x{)OA}m_LryVtT2bq-7`9b!STY_W4y3j}pMIxVIDUBbOpsyYz&56#eFr{RBDuu|{QG=lCbv3q$BXKB ze|JvNXr;WdKs1vA?vFn5K3ilb14M4%S@K)8d~; z)&Bi=sn2-$4{oqBB;7f%%hKZiI7#h99e<*t@ZT_J)8%Djuu0wsIzY~Uwm#&wt304< z^*x1<$u$quY2-lOdtfDupACqo2Eyg72@qC0g5e-Lbas9oGubPa$?K8&AAe;AXR)y+ zi*OfhdxjisY{}3d^0*dZ&Ehbu!qWGtu*X_tC&*=MElkVtwGSZRp+VWz;9UGFVSI}u zDkq=gWnhP?Rmd#GW%u1$G`*}CCnR?=ThDZ&_x7lpV1S~ntxXxLp`t>oC8{ol0e)aP zb2C#{Z6vj~+`6@WQ}sFg3w`dKri$`IOd-z_<3I=qkAo9z@ma!-j~?@w0hyyw zZEaF{P^l+R`dJLXHJc;tH6fAoN%zZXr&LMa#q$kG5&eT$- z!X9R;LsK*3hymap9T~cO<>cg~AeF7^aWrAj4)(Jzts|z{Y^QIoTP$OV;A7uUF77pb zyTZJEgi<}T<-w~D?3C6Rr&q_vtH&n(9(N0JMY78)WL*Z1_d_b8^Jfd!Z>~+sjpxN8 z2+uAq-fQIC-ClxZO>RX6UIZa6V4O0=`#yjFd3R{YdSMEXLHY?D#S6%)C)EUBzXlZ& zP>8|;V_MC_!()Xiyn6qzXZ!)K%mZ~wdS_t1cE6VuwJ6>w=+__k@KlACnKk%*>&KVF z4rD=wUV|!kYxA!MT2;o6m;KFQCVXX&$vL^Xm5hy3|8s`HVB1GWgI?n@(P>tplu!H) zl|d!uXRDW>$BHzMSq-4Atqo$m6h&{!RQYF+uAF111w;rfp(qugZq~VtE>Q z&@0j`e%)$4IZ!boA#1EIZ(xYJo)H8HbqLV#Mmh{iVuk^s@UPDB=mY{NSOH z4K~A6h%&e#=zh363VX5x2`?l#cRk*Ecuk4AccRS3kez(zIdy zD7lX5_>$&k#*b7P9uqv7zYc{4SITfM?=yD)*b*}in73bH5nF%VATApDIZ5_;miz3Ei${8<3Ofe}{XTQgqe1-5KF$^q5kb}G z+*Lfq(QlMpyU^qCJ}QJEFy2H%g36oya05M_VkFqzpMp|9@QDiZ8{yU)Lysd z<*1UJ(7=(S!nQW5oZFvU6Eib*G;|lZfE9M6;*cE-QSs%CruRv}Ve3|}bK`r;`m?kl zNj09wBI5j*iHabx4_$Sy;8}NK>_oOBY<8a$e0_bH(uQp=50_gm7G2bW*}7K~KyaZ4$;g(i z&q2yBzOJ93+V-kQd`PM8AV^2WJU!PbKpqyZ>+c3 z)cV5^8s`L?!91)LS<;@9u8ruxDzI#SJbq$xLc{-0`1wINIlXWrjPu1Px5~ZaM70C_ zZ{`)-g*yBBjsRi6hc^6~M&Lt|2?{$IJ`Nry2}6hHbxoh(Ru#(ux0(x z+1Xj#ynAf_o6$^h+wV!N&PeXm-h<}V4|7(g@nPm4|EcCzccI?i?-)s`tFOVuJV_-GR1w9>6o{48QQ=8SLiG956BoDL z`%x*7Ir?uLew|FRUnSX7`u+68ls*5V8*_p7RzdoDBRPM0_#iUuC@5la(SpaaMQ?`a152pS6UI~xYZ%TOq5x|V+y3AZINJUqO26IFxj3N9|(bV2*Ig3Upr8KUzI zPRXTd!PlE|I7cNQjX(Dyi1*@f*~KX60SEO#kK-#lK6nmBPKVM=S9jA$!kW#$@mAI#*bA(}B8ypzJsa;Eolkn63^3V1yqPo*Q|72_12$ zxA`}-g0=$!Cl#?CA!Ls_9Z7@&Rm8WXbq%)53O`Xfd3X%AA9cloo(<-8&Z09q-ug>{ zB2x0_-bDAu(Q>F5_RkcinU13wk-X2Ea@#EM`J0=XUak+szjASbehZ6w@A;fn0zS1^Zfk3RmZJWrbs;W{2h2j^$ zPTvFy#Q+uqlRth$9UdM^Q@=4N)6)o3;_M6p7ix5Tcl-U#Ubh10vKD7#-x?B}qmysU z38;y@lS<+vxCH#D^YT~6=@=}xO5yKY`oF-lW_PkFcW}A=lSeF`Jv+}ihh7Gs08@%l_^8A1(wSgDmX}?}kq zIEoSsG>!s}dob1Zo%ZIcA0&&;95gsV{X^%;fr#|^jG;fc!Uhf!gCA~J2aQdiv*Im* zfxdn+NKfweyv>{QG5?FW;$pV79q3gs7-@k9&|Cm7RnL^Y6$fb#?Au;vgDqXT4{1GS zTwF=Y1^0X6{FE+v&XAM)+oOc6?VFtYvy#!2sfWUpP=y?N8xZHN*6APKRX%He*>UvG zT=A8+`6rWW`s)2fvp*^vGh)}G#u{Q$W$ENiLWwwvP|U+*7X@&TD4<3I)+bbYy8ppt znHF~H=Ykiepr6tWI+JARUlmV@y8QSnTy&;$_pmvfW;^(S)*nn81GFV(oq?i<9j7{Z zD@|@oQBMwAlx9rCz|;sD0r<37?(mS}ig|>YX8;Ej3+l6zA%&0U6E$uO{L*t2-T@|h zB#&}anhZ7C!Q7v6SNhrZe0TN%Ky3jdZRK-QQ*T!94*kGpV!iE$xnp)Dm_4j`xN7~5 zUId?Jt}07ZP(6fp(}So+vgt@ALx4I`%G3Kpf{sXzF{yqL!igvW`~lQI9Z@QKz&1f9 zDm*;g^LaTK>(828iO!=A4i4s9Jefh;LPVzjLE{`#ss?3sCk&aP$1(F9iX6mzGoAY= zDIKG9j}w$lQIl%)qHI9}%AgYogwXnLsi3ti_;zV^U@ZGdWTt;VnJI|#u_D5V$D`Um zFKreXQ_Ze>I7o30AaFi}AH+u%u|AlvK5TVgcYHah0d^DfH8yX*(X=hQOaDFYGpl_W z`OaGQcEA3WhX*;XpCW!`cWvPLZmcFTYCRQA2LR1uIGxRW&)Z0KWv^L$_vZz>RyfpK z4w{x~+?S5t(EP~L6Ar^vw!}qhgNQoE3Iqo;2tfR?ki2c( z-C%{;aWubSkm=hs)hz-6x9=q&ShG zX5gUUMjXkH`Vh)j5-+xTv40LaZ0%5K8dT&qsX#vWTV|~*_~)7G0!y{na4~#*e6x06 z-i*x5_?SX9z!e0#R$@R_maqbL>;i}ZW(}{1z((WvD~WF0lD71EobWLcM)0nmod9&r zfoa-!1NU#JTvh9DDzJX0B2ljfTEs|k@IL~_C*js#epj19t3~v*_#Rlm$G}sV11zOJ4y`AVA2**6u0&+~kwpVA3FwqX zsQOL2Z;X4s)f#AO;<~r2VeBurlOx&?m;7PZnmjK$%v$2cWiDwx@|eH0tO%5xadr8Q z|Cd4mZU*b2Mo8&hpnBe3EzWgCwR&xR*s}l$sefq>j0d2U#8BpJLaF8NpJ8+?XgKhD zEZfDh?BYqeX?0f{8X|!l?GE5WKYo&}4_ZA2wZ)fN0*}o&lfR>ymfZV1!pcAJCEHWV z{aqtaNi_iNN&m`%`Z1|tGmK05?-&x2u23lI#jCy@Y{r(WU$G~k-EVC(&5Eu$(|_4# zZtcf253;z>`|B;w%Voc}2B1+gmOo@ib70NI3xwRBB(y7kRtQ0);wt*FJ&@w{0mO`7 zZ*cjLZ9aQ{oS6m{6XfnCx-L79Vn)_dO<%--nFP582eXFtYFEehu{|pp{1349AYbYC z4u%BT7cgDn{e<`7M{16I{;$b>`TaBZRuz_apyi1s_$1Z}4fvNU2Hq@ob3;%YPz5X` z`o!a3{ttJTjKe}*e^RTFd*|Tbfq+f-Zw5ebBf^U>Nw2fBQ&m1dQw;gtiCil(L)#K4 zrRy#bJ4UWB7-Ox z{M{PQb=8qfaSzM6skCCjBu1fm3j+u{KUO}#hk2|iPn-C+MD7k-d^Nb9!tO@l)k;Ue zK-~>7p!1rL1B@Vgh%0nPV=e7PAoiT47e0uf0lf+W{ZILMApgzGXnn`{#NgYrCE{ca znOS@F&v7I?F3oJ&MWPRRb%+zG_+Xg?kf>2fWBBM-U;!O>7fYpIzVy5$kd=X7t`MHs+iLkJ2;h|(e5t#k<@A}QS=(lO)=%{|}m zu66&tv(}k_nRCv&-@Tvx?7g4&+M3GvI5apQ5C~sY<)tq03jgnbVgk?o(l*1u3%ZZI zsy-BWgh1`z0iUtGR7`w8AlAMA4zM=s&K2-SYF|ZTUp-F;Uw<3#*C2m?e?DgqS06hY zuh)E@-j3PFk~AO?6G-)?oPI#gQEp%-ok1q5$Dqr9Z7CF$yy#~JgKf(&}`zEDtsOyRRm!dX(8<4DcOXPK_x24i&$K8PViC_>yP6ReIU*`LD> z=VPU=E};&+=dH)_B=HljlLPwydt1B-+^<&FBMYBwml?0D~;F*Ac ztfwb`S63Hns%qnc4R0j4Gl8`~^^cP;;!VhaYJBP|xG|3*co{1M^W+8UIz)O*{D6mo z*Yu^IpNI!4rHwnQG)0Bg$-~28Fp*wZMC8NEcRO%?<)S}xiMvV&?Hn@54_iSQFUk{x zFC&YVmX>1+j`^dVf#;n3{K`6??1HSA(TD6mCMFWVj+cj)JbN@)QaHuXulU!yorK#F z8LJ^5|3;Ja`tD2Pgpg*uc9*$5Y--yrjOXN)Qa?va9(5Sn@^;FKDRtg$x2z{v_ipp* zSZQ53j`T|!jG8;3N4D?zW|fw*i+Zk(M4(}Lg~ZdHAbOt{{c+l92znfPN2&y@4n}@r zv99jAba#Coi0W9=U?q8R7%*JQL4xhwwyd+Vw0TN|^A`G6{_6grA6cf$MZh5WtE#U1 zm8)x&&m7Z<)VdXLzWs6r+mC-+Ndfb>!KqF#T$jE&Pqm)?=B}uy;1U!4{7O(*_$5n9 zy&AG1W_XK*3`Ymp3nn7Y`RBsIKUTYf7rr`^0=qC~Q3sv_thc>+rp-Y-_!(5{|4232 z#6d^kGh<*R7>qoG+~&P1**!jHPaR4Kl4AZMlUd}?sWxt%*8MUuPi}9*;8PG6ZeTg* zrLN^)Hj)d0G~A8z>+-c$S9g^VBQl2`Ug zk7Zi@JX0jkk(NkMqt7G$Zk_!5_iu=m+?24XscFDoNxGAZOQUF6L=vzt<-toL1c;6j zaEk_ia1Zz?$gtY@J6pQBZF-Q#)b8G%jn~P^7jt&U`qx&Bb%)0`Q>&IF&PN%NdcP3c z=Bap&}&V?#Z>JYR_6Krb?O5gD*SfF9#nvjVXn;911#hdzOV9m~rbo!tc$>TR-!b5b*~V z7S1?%4GnxYHa2AQ>Z)b4$I9|`x8#=x@Jv?MODZNt1{1S90?K5VwyM8VJR zTUSpn$YvSh4PIY=0Iq3DrEZ*)Hhp%!!M`xeaGYu^!Kp>b#g#(@gya4$u;&krH*; zXI;-A20XKi_6lu1@g+;hzbA~$RCo|;DolEM`T;pg%f;0JJAS&tn&z;#RX1g%|Rb;D8_zh7g0G!-qQ{pmC=Cidy6^U-ehpVdDtVr6^H z4jG2WI|@|}Z%&;J#GHYsDX*xIGk9GU$ey}!)UxKV`SXLXzrR`ReXuZ1mrqW<^W;Xx ze}Kh{{($)@Sdzh`=N{I#A;5GL>gwt$cO_CqeYb?5y&gvL1!oVJ#ASYa9qTY!x$yV0 z6<1%*LXqjKb&#r}MIaN&MO&wI(s0q!xTn!XkZ1v;$rn=Dzjha1nht+OmSVzM@GjYf z7Hvv|(x;??Q7ydtyN71Qw0&dmTbm(Tcix z-3a7=P%0v1yo{B^7IT4Q7TNDl>8DJIuo|2N1FPdvY8CxAmh{6Ux6=`^( zwh`%GhDg))jCHSTRb-n;!O3Li#=eQc_qZ=}TWA7r@h4>zpwT)nAqvp`8xg#hW>%Fw z4ZMGv>O#w{p5s`Fejd9)b+5pJjK+KEnC&;}>hMpuveUK6$L65se3X8EelXsGy1FDZ zEP|7}^Kmn|r}(W;X+B8%>ocCSu9ritF$2R0&9S`zxM*-&cqUtUUs_rk$+?=B#@vm% z;tPr`7KL6(-Auj}vgC>XG=Jl3nkmz|f3bg)NiOFMCnrPS*29T5r9#BQ7ZOEDyP@Esbw_F-oXSappCy5pN>=gfR;lAUTpDh?cG< z{vkIv_i&*p_5FB@T+6`voB7p%RPy=a7Vr$e;QJ?^AWh~YltQt7z-okWT{ zMDi1}vxY?Tfi|uj4TJuvzf?Y$GZ3(*^X8tbtEyTP0lOSvlrQ4G`nt@B|0!-zR9s|X zdE<6D=PyP@9kSt95-h4nQL^Z3wLf*0PhI@xjh=Og8!zVnDj^}5B03445;53_>I!}T z+f5U<)CZH_?WmCbubt+H3tIesrqm&AUERn+PV^|bC7!P>EokU)l-qLaDP_93_s}kJ zjcW80b|CY15SXmkArBYKLoa8Vo2F-G4AxKX{fwTw<}XHhP;|CusSNI^ImjSRTSedl zekEJaErrjkv);)Rmg|mH-ibV_{;_-YH3J=j&KY+%+JE!pkRHdfWk1i1^eN$uxr=f* zyK~0C+!1VV%5|84fNe6u{;8P*^gDM)aE1k`y~Af;hnk;d+MM>Oy*uYX(yd<9AQ#yA zsKys~_fC3WwU?Ngv4Rpv1GiATC(Sjrjb6?qIXs(r{TfX9Cov|0sg&8L8YHeSQ zg(Fx}Q@=F@HdUfVvrL-EWev!;vd&BSTfJkm+r{{4DHAeD^5R{x_Gsx!~3uO9Pd?Wp7M z>;3|pBCD~O=S^)~<3|OM1sb(&5<-4T!$|)fq|nIFP*+FCW-biQJ+h5ZO?vVNWF_nH z&-;2See*Ox059tM_wSH01V{Mt;267!79aOz86|JVb>uhC8RwZM2O|vb*fv-M5jlo_ zhuh9;NpJ48cfm^Tlo!Dkc?M+$<9xH|!)aq-yi_NbkV7|`e;nZ>9M~q;I81gJ0D9$w z-RxPr1$tknTf|gW!uI@y zKc3`|GuF>hj%Po-Vp>;=OxUe4Hx(gNXWs4-vZ=vsTJ@MYz*m^R3?!he1Va^PAMh{+ z1j66yhNy2e+x$uYMW4n?gLkDHPcgml0yO47yycCDhX-1Rb-aJc!eh}so!NUc3eel+ zL-7Rx4@d@eXFeoD#Fb*IR6_w8QG0NKLM;t;HFm^WIB-=|@K9G*CkRLBk5y?_)f$m0 z5?{;aaS?N+hR6=${Dt-dw(9*g7-kP55+GCSmB*pRqOJx|Ezb6lk`tQqIGXJ!qA>3A zv))n=9SS;?9`oVqKuu)Z)LrkAMi zj=R4jW|7Y=9k3E{N`t)B9X{BLfQd0A)()85q2^p#mHtd9>-_13^~D>tlNhzhZ!z zA|tEMaxJVx(bW10QLeG>#A&_wu@m?ae5y=%3SaDMpS)-Kg=@*dZ#>MZvcbKJ6PFkz zFng-t%sarjUBE-PU&q7$0y?prYumNA34$>L9xuxTUpj(qOLTKn#N+25OJWP~{YMx< z4xMW*WW@6G6^j&kcU1|Y<&}NanId(@OXGQFhV&=xoy~ST#?{85mM4AnWZ-Ajb+m>7 zR#5t~6E-d>CWVw=Yp5$ zaF`Ea#Ie*+G@}IGMAD>FC-`Ssw%(i0;mBo(+DQLs|A+}^RLGi$|*eSk9`SbJ4xKt zLE@<|i*9P0 z-CQHu^oVgLra%l)OmV-JC;34p*{9zEBNa$=zz8<${-vg8LlZ%-fp&}hM(Fp^AD$b@ z_-$IR9P9@96-ID<@n#1VSVr3GG1(Xb`XwO-lGd_Zac$GtJ#H{v^caW%lr;no#s1itKq?tvW$&?fhF5s9pS zr|TO+yRw*z0LfN!zAFFeuPM?OLw&~R^#<+aS2|=RaL`sk2u*IOGe(yu0GmGr1jr1a zXn1>f*ns)&*EIKZqh&c`cx4oaaE($HBSM<$EQE-XWjH)>WL+>oKuF|Kw^jzb=@n%!BPqfd6%Ak zov)>lP7O&L&@bY%_y^&lWf-xynA$2J$9OL9j7-(Hz@=g(Ys6Y|lRXWw(JU#$>f(J3 zIkJbzsXBo@-ttvA|MIlF(VknEbiNyY8Q=ecjGft@C00Nv(B4f}5Tk~Pl{lJ+6Q=kh znc&M2e%yWT&^^B!VwuCfyIk+mjH+r{;=T1Xvx{gIhHPc{j?7;CId4zC9xcO{RAvzv>e%vq!sNLiHY0Fkdb1cNf?N*v#JY zN=SQsZKeBa_ZNf69FKPw+o7R#kAY@`n#@0(ctiVnZ8O#)FLO$~yM_vy2F!h3FG&UhG11b%F{`@hlHVhS?4;XE@cpFOFV^aa$F zC$zQkhRrj+VN~Fg4QLlr3!pn}*L}uv!u}}?&df=Gxt3uxtcV1dD?%{tu zHE6Sfb?-9vtGil1(OwovSNS0>gCUgM4pBr7F7LPN_X5J5ZpZ0*DW1pEj9dd7J>A zg5Zc%0cW4UJzD=H4xG;dOVJuzpIH1^y1MJ0>QQ*0zyGG}vvfAALIbgpZN0DI)stcK zldCf_gY6U7xWng+Q%B7r@Dh5`_t9)23UimuZ$c2Hai>`z-3qey+M)w-kCvrl3f}1T zk(+l%P$ZIYR9|*Yu{ud$@kf3`}679mp1s3w-_ow5%J?2bxQ}nBQvDe=6f((#5hU*9mUS3}HY|#%NKaxnGMrrPxuM2T3 zSGj^mV7|!F>U_NeIuo6L!U~?ps`#R);vjHM;g5q_&dVEl>6U#|`!sxR3mI(Am#=K_ zmQPsYxvt5m%8u`o+#%`;^)Z#^i_-R`+etLCMWk&K!&&#JS7q4Cjl}vI>tNU5x|M6s z>%uz$Z^Ht(BseQO+r8#Gkxf>Z<|uK%mLHPd(%f9=oRxd_4|RK#(m0lD6{BV_fiLHe zSnp(k7}v{qjBAUo2z{bw;aA@&Oscmi(pJ%o9nEZJ6YKn;rN4{U(pBZ?Mf|J4X*}0c z%MU)j)8wjT_^+;_Vu&Oxw)UV-09$bN>j{UPxG)+T8n88FiVcICSgxFgdqntnC2Cn_ z!AfcoG+YnI%QBOKPZk`aBm)V~mpKBGZ1PX7edg};#m#WNj_s8M8u)Jv3%LDQ4=!LlYV*i!LE$N zN)krY4m}2opLG(c5toEZ{L$J3qW%_;XV;qw=?mu;L{RiYgiYoTg;;`xeXFUjsud45 zIq2_{im>3kAYl7o$g0Z9XnE!iiHpO1rgZ*Pg?^xS+xwu9iyPav>=db^v|!aaer?5u zxM&+)OUZ%_2Fv6u(V|-ThaJ9o!-vxiggQ(`F}9CzOmN7;VgUvZ)VA{xNsW!2ym#v*O>X0@B{e&A8dJP7cAb&*9>J%D;$aqv z0~Qxnemt9O{Wjz2Q@);R!3Z2slG;{O`nFn+h+9q}C-NQhhYufs($}mg?tUqXP+Cl2 z#E%1mT^_WACoC%ZQJo!_xnT5{fc4*o3g_@=cL|w{HdQrjh5R%+iy<8|?&IgXp4C;4 z{Cgf(C$iX=xT2Bnl4S{*@Olug>^rmQvgD7D1sNFp@uTN9?|wZ@p?1Y(G56>PS-Xn1 zTmH{>o-BiC4v5vR;wk5wo14RlG9UXRTBDJA`eNkR1KTa3ACGNFAHC+p6YS8|Nh@{8 zj^I?TBCncH!LadEN*HRwe9SRq@1E_4`c#&3u4(X|9v8* zXW1TRijrSy@#M(WOnp_NO@`64<|z@)27aF3;}LO0bSyL7*4PyMCFV5Mnl*xuBcN=b zpFJWK+Rxj+G9H$Q9P6gW%I=_%6%PZx4`u_a9s(un+yT&te3qH8B6W8;XVns&KjRlV zNl5ve8S=8|kIWM^1v)XW-n@dTrx>3-&)$3Q${`s1Yt93 zQrQt(i)~Pn76(u#LNR)x-unj3t)`)Hnax3rUABo9qXq__fw5l4N2iBgr$t$ePv)BEJ>Twou|!rl!L5HsXlgsOw|$<**^b*+a>gCu zvXNjvoTc}OsXh89t}o?zsb_$IVIlX4PqJV{Z7pAWd;4LUT+f=7fZ8vPx~##EnT_G* zESVMaUrSE!qZBHh{VdxbEV_v$Z9acAHZ53ZxJMg`GZIDI8;hq_p&37G`lMwkG9H!> z3^SHar-Vlt7!cPx`Tjre5usNbu_&#Jr93>k_n4q7ac@lC)u01jy#6mWHKPO(X=!PT z1_~`|%t2GOrAEKp8UPgN4m?SA=ww<@MSjP5J#pYLx`W__Mhe)o@b8~#!oVt=n8zyQZz5bJ|^ zs~7Wd8gD-WrDtU1m<5ZRe2m{4J3Q&!)^}9L=W-OyA75#f{TCP z-*(aU44`wff5i1VT3l)@Ea}>zVYbKp+t^0jebKE5b7v=ll3k!Yot>F;HK*3r#_f`% zl-N_>h~D|`w?AkAL2|z%^LSnC3W5Dq5drneph?Jz%U}`EF#%s+$jo;EaC!EaiukOf zEqqkBR@4q%9=fINgX$3Nxr8Y^`^;tIYi32aMVquEvwUMCEeGVd9^}t%)bo~0b4TpQ z-#%q;9qe3=%Bq@bLy+R>umLqR;DQS zo3BDKBUqN;r}_r3XDSW!_4Tz)Oq{bp%;>P@TL0=c#qh!XCNIkW_*ZaYV+674Uzn)r8T|r&pWe=lw7>LCGG8t zvo1hq3UKuEX+5pe!=~}Zz;g$Z_vrDI{0>7O*?7#F6D&Glz*w92*~M;qSifhQ=yg8` zL`d+T79jj~i8AR*fl-+*{zcR(#vQTOZHV;hWvxFk6urE%@@WRy;*A#tjE-griBKchYRdDGn3 zPQr_8M;HmUwY4?8Wi2fG7SWTxc7N>OAC@G`)cbADau}E{FLe0VDMlvL9)v%30RJ@F zyej4(8+4himxu^QFmRa1@wKA4IyoVVcP?Njn;btrF znbC7%6Ndj*Eo!NfyI-PoChf?GyhQB4Q4C+;@D?0bOPiP0oOf*@6*Y_*t6S#UZll&F zyuST2-qCv|@EeR4Xsk}p&Mr=^M&w|WG&QA~d9=Od1*~_iv43!Iy^ba0^c-ieoev(o zd3se`$-poih{lL1jqmMSy)~R(RQWVQ7VbP%y0E9)W}evG+#Kor?;~|Chn2@RD%fS!+@+HB-z(?DakMgf}~7c)R*oNYs~e>Fh@SgrNs_1Zut6xKWJ`~RYt;xS=_Sju_b@Mv z;q7}F5aYYSO^&X*^@cq0X2`WSC7jMEU?7eh&;Z!XRN7Dh`E_b;&S;{@5EPt+weYnp z-3WEzUG93dFfbTT{Ta+>Z2TPqci2R@=&Q9UcGZieASK*^?S15u=gwP7OR7@_ZlhZJ zqs4FV9Od=n4#a=z3piflO33->Zu@bL(FXo{R)A!fZvVib-IE7jR>3LXGB%^__PFpRY11uI4+t@sG5~%-MtyBL~02lwM zw&i{a``^u+z%3Y2<@x~mTL3Ks0|Vta(#`plftkumbG`~|z}9D@Rz&yh?(VMGp*TgS zCBN%j?d$8aEx3A74{9|y@KkZ{u4BccwtfGpi;Ihsb3=!@D6{IxcB}O9o8C8>(hR^f zcqKl8oD-y$&i8t6vgEAvNk~Y)fC?)KD+zY+?o31SZxvD!uldsy>uc5m?RVOo`r3bE zpV-*hJvo@G8{9mthaH*5#Kb7I3p2e9Sqnv-uA;iFJ&DU02Eu{ZVNd_?HTVKZf#Uqy!b^Q#4*(#9c; z5V5x7By03R2qG3^`SV9nJ%@g6Mvmu)9r0O7u3RetQ4q@4#X682&Fs`D%kO z+==vHL}=Xpa(~8crF};HCE9TU;2o0DA?Da1VC*!8o`<;&CkqMNWTdA@3QViN)YXj> z94WC7|0hr>N|F{oMs*_>UE3ZXZjVBS9P>ld>ca02U`J)$-Fev_?eE=}nqQ2r_JB|W zo2R5{av{tAyO+w8nf!wqcaN5A#dwMaKnt=%ClMgY(D8%fL!Hlby~E8bRuT+_mB@r6 z&&975twON{2kkgs-!UjpPLskz!%pLoWD?)LeXBNU ziV3|%zR2GZTyF-vQ@%7`iW$8gFn}Qohk?0``}yrXFqQe#=Ci|4n#X(TKH6vV`S9;% z9CI}11X&sv^;m-N^XKP)lw)dfai6TS_TZ*vG_5Pv&tm6nn16>b{0c2e3OOIW8u)N` zU2WEqxM)gkDGiMB{a5@bH8eEfZZ>zlSki#tsm`Z;g))K`X#0-WQ%c2%75wimJYH5x zbQJu^vM;EuP2At#hwRh?1s(+vgp=sXc&tdI<{l8w96Gqgu7_6~+{nn>o%DCeoW9rk zwBNp3=xqAFvT`}-pl12aAU(j76NBz;fic>76yqISG5+5H5{Wz;;15e_S^XI5bRQ-& zXB95RkQ570rh~5*y)yumtwno?$^n8YIK9{n`bm;(jZgm4QE78?+TK(dC3AMVh$TKipRJfo_~Mnc(@?c zVDdNWJJZ_rn1Acl-+1r~tkbz}q{wWY6`_iIOVFi@!)VsP_b9BIDe(x@&&|^w{NO)! z@Khxb?l$N*qk?8txSBc{0YBYAP-VI=6v5wk{NTY>k=*S_iHl`xva9< zilO~C4UVz-FL1-IhpiS7?Y09re68yc0O()mra`1hk_HDEqw+wS;@}(sNA^&DZ|}D# zP{g0{b>>q;i62YA?*If1|dQ7pW@`W^IUT&{d%S@Yyj2s7`o*9Uq0ln>zp*v5Qg&u!4b>u zTlB_Sm$(<(=i`1=vc2(pI!Ax`^JB5-#~7}YHPeC4Lc zW#*}<`{EBhEh&)=F+~LhG^@v(IrsBsn|@3Hv85$i8i7DWbs<6r@Kt3X(u_%iNxRpv z{l#pug0>AAkvZ+91gbIOgFzzR!c7#e#PVNv+gGa(f|l-4Ycl)A$)s`$3jJ$nGMJJ~ zRY3p?T%YTC5UJ32k)IPsbe;zQCOA@M`%mpf*?oc$6jXd}rKi_F`h3l;EI%`V=1N7A*pBk9=mSEr}M>ZxGTUEbWq}1EWN~q1qB;;_8gfDOgnnD)ztv_F}x8)Cg1|) zPPIkHtOqwW71cW9!v^DgXP^*M)%!VfAN&okc@GCn0^n0W{trP9It|@jt`7l{-Q=y% z0Q!FcpjZO>zk2yMpDflG`P(}>+BOp<4%7O*#76oFWk&XAs=(CWL(YacoIO06IgVe( z%v!lPIkopX&+Y&vC#I8=lNVS9{TSV3&FY&A z`MBgc-nbNMIlAh7%W4pJ{_XP4q08U@yxYI$)}kXT;3u_XJrG+!cG&1d$^c>IseZ9g z2KB=GGcj?(W^Wb?d8X!czC9{=Ijx70>Xw96WmLBvfyGd7rfe0o$JTu_Q@2R(4zRMe zhnoXuH#c8V)_c6xCk#nR9YVj!z=?vu;yf@PNKepmf{)w2e*1PhZ~d8UV&m09HO0Q@jJu2B7b;o|3l@I1w6fB15Kw0|QVz1+Tmg z8AISdVn@yORsqK`y8rgRY#PAsH^AOEc;+XV$M)OQ_B;ed69MO;g!?CPoCj1=c+n9h z4O%IQ1|q=oUBS-*RuQr-l;42y!V)NX;Y%JJAAn>7_J$zxf#5R-vIV^QnKZC&9;@!< z7bcfa?OhKtGFW=w$V)P;#j}1_W0LrNyxNsL9)@hl1XPkgHPomzX;&Op4F~e#Oq=#3`zcI2+S~iU2Q;U^ly{#vDG4S zFkYR#AK0<}x-hoj^tKg`?$ZyR^Cqt{)nkGYUFqjz662YesQZRmi7m7o5D*faz5W6n zQ`~WV@CS4ebWh~2B3>1eZQv#+^O`< zkYd33=<~Lt<|Q$&b&x!?gSfJ(X-c(RhhH36JS(vH5&33zKC}gvR<8~Du(QQjbyjAEBu04$aVfM$#S;K%{?&FI+XBTI zX?q%=3noqcwf?yuKEWF)B6{V2Pzq7V0L1_f29+qF)fo< z{s7{veo`$-8>8!=^2E*z|JhD>@l=n-ngqBx8Jud&Ah2cVr2(MK{HZ`FC0t9>he2s> zjZ%0yhN7Z9$l<~lzy`vz9{q~(3xoY(I8f5M&DTe8%uuUH<=M=Kr5Jr;p%IG$Snehr`-{lpdt2sQI#1 I-a7LC044CYApigX literal 0 HcmV?d00001 diff --git a/images/old_zoom_out.png b/images/old_zoom_out.png new file mode 100644 index 0000000000000000000000000000000000000000..f7e84c98e160ebe86263fc9658e808f43d7b854e GIT binary patch literal 11522 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 P%7DDAvP`A4Y4HC6euIu3 literal 0 HcmV?d00001 diff --git a/qt/app.py b/qt/app.py index ac59dd0a..7a5b60f6 100644 --- a/qt/app.py +++ b/qt/app.py @@ -7,7 +7,7 @@ import sys import os.path as op -from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal +from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt from PyQt5.QtGui import QDesktopServices from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox @@ -54,11 +54,11 @@ class DupeGuru(QObject): def _setup(self): core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto self._setupActions() + self.details_dialog = None self._update_options() self.recentResults = Recent(self, "recentResults") self.recentResults.mustOpenItem.connect(self.model.load_from) self.resultWindow = None - self.details_dialog = None self.directories_dialog = DirectoriesDialog(self) self.progress_window = ProgressWindow( self.directories_dialog, self.model.progress_window @@ -152,6 +152,9 @@ class DupeGuru(QObject): self.model.options["match_scaled"] = self.prefs.match_scaled self.model.options["picture_cache_type"] = self.prefs.picture_cache_type + if self.details_dialog: + self.details_dialog.update_options() + # --- Private def _get_details_dialog_class(self): if self.model.app_mode == AppMode.Picture: @@ -284,6 +287,8 @@ class DupeGuru(QObject): self.resultWindow.setParent(None) self.resultWindow = ResultWindow(self.directories_dialog, self) self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) + self.resultWindow.addDockWidget( + Qt.BottomDockWidgetArea, self.details_dialog) def show_results_window(self): self.showResultsWindow() diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 57cc650b..7b3f07d5 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -7,16 +7,19 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QMainWindow +from PyQt5.QtWidgets import QDockWidget, QWidget from .details_table import DetailsModel +from hscommon.plat import ISLINUX -class DetailsDialog(QMainWindow): +class DetailsDialog(QDockWidget): def __init__(self, parent, app, **kwargs): super().__init__(parent, Qt.Tool, **kwargs) + self.parent = parent self.app = app self.model = app.model.details_panel + self.setAllowedAreas(Qt.AllDockWidgetAreas) self._setupUi() # To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog # has been shown. If it has, we know that our geometry should be saved. @@ -35,10 +38,31 @@ class DetailsDialog(QMainWindow): def show(self): self._shown_once = True super().show() + self.update_options() + + def update_options(self): + # This disables the title bar (if we had not set one before already) + # essentially making it a simple floating window, not dockable anymore + if not self.app.prefs.details_dialog_titlebar_enabled \ + and not self.titleBarWidget(): + self.setTitleBarWidget(QWidget()) + # Windows (and MacOS?) users cannot move a floating window which + # has not native decoration so we force it to dock for now + if not ISLINUX: + self.setFloating(False) + elif self.titleBarWidget() is not None: + # resets to the default title bar + self.setTitleBarWidget(None) + + features = self.features() + if self.app.prefs.details_dialog_vertical_titlebar: + self.setFeatures(features | QDockWidget.DockWidgetVerticalTitleBar) + elif features & QDockWidget.DockWidgetVerticalTitleBar: + self.setFeatures(features ^ QDockWidget.DockWidgetVerticalTitleBar) # --- Events def appWillSavePrefs(self): - if self._shown_once: + if self._shown_once and self.isFloating(): self.app.prefs.saveGeometry("DetailsWindowRect", self) # --- model --> view diff --git a/qt/dg.qrc b/qt/dg.qrc index 545a9806..760f2a85 100644 --- a/qt/dg.qrc +++ b/qt/dg.qrc @@ -5,5 +5,10 @@ ../images/plus_8.png ../images/minus_8.png ../qtlib/images/search_clear_13.png + ../images/exchange_purple_upscaled.png + ../images/old_zoom_in.png + ../images/old_zoom_out.png + ../images/old_zoom_original.png + ../images/old_zoom_best_fit.png diff --git a/qt/me/details_dialog.py b/qt/me/details_dialog.py index 935a34c6..ecb947d0 100644 --- a/qt/me/details_dialog.py +++ b/qt/me/details_dialog.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView +from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QWidget from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -27,3 +27,6 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) + self.centralWidget = QWidget() + self.centralWidget.setLayout(self.verticalLayout) + self.setWidget(self.centralWidget) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index 07ecdfcb..d96dba17 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -4,11 +4,12 @@ # 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 Qt, QSize +from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame) - +from PyQt5.QtGui import QResizeEvent from hscommon.trans import trget +from hscommon.plat import ISWINDOWS from ..details_dialog import DetailsDialog as DetailsDialogBase from ..details_table import DetailsTable from .image_viewer import ( @@ -25,9 +26,8 @@ class DetailsDialog(DetailsDialogBase): self.setWindowTitle(tr("Details")) self.resize(502, 502) self.setMinimumSize(QSize(250, 250)) - self.splitter = QSplitter(Qt.Vertical, self) - self.setCentralWidget(self.splitter) - self.topFrame = QFrame() + self.splitter = QSplitter(Qt.Vertical) + self.topFrame = EmittingFrame() self.topFrame.setFrameShape(QFrame.StyledPanel) self.horizontalLayout = QGridLayout() # Minimum width for the toolbar in the middle: @@ -73,6 +73,10 @@ class DetailsDialog(DetailsDialogBase): # Late population needed here for connections to the toolbar self.vController.setupViewers( self.selectedImageViewer, self.referenceImageViewer) + # self.setCentralWidget(self.splitter) # only as QMainWindow + self.setWidget(self.splitter) # only as QDockWidget + + self.topFrame.resized.connect(self.resizeEvent) def _update(self): if self.vController is None: # Not yet constructed! @@ -88,24 +92,9 @@ class DetailsDialog(DetailsDialogBase): self.vController.updateView(ref, dupe, group) # --- Override + @pyqtSlot(QResizeEvent) def resizeEvent(self, event): - # HACK referenceViewer might be 1 pixel shorter in width - # due to the toolbar in the middle keeping the same width, - # so resizing in the GridLayout's engine leads to not enough space - # left for the panel on the right. - # This ensures same size while shrinking at least: - self.horizontalLayout.setColumnMinimumWidth( - 0, self.selectedImageViewer.size().width()) - self.horizontalLayout.setColumnMinimumWidth( - 2, self.selectedImageViewer.size().width()) - # This works when expanding but it's ugly: - if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): - # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", - # f"""Before reference size: {self.referenceImageViewer.size()}""") - self.selectedImageViewer.resize(self.referenceImageViewer.size()) - # print(f"""After selected size: {self.selectedImageViewer.size()}\n""", - # f"""After reference size: {self.referenceImageViewer.size()}""") - + self.ensure_same_sizes() if self.vController is None or not self.vController.bestFit: return # Only update the scaled down pixmaps @@ -117,12 +106,44 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setMaximumHeight( self.tableView.rowHeight(1) * self.tableModel.model.row_count() - + self.tableView.verticalHeader().sectionSize(0)) + + self.tableView.verticalHeader().sectionSize(0) + # Windows seems to add a few pixels more to the table somehow + + (5 if ISWINDOWS else 0)) DetailsDialogBase.show(self) + self.ensure_same_sizes() self._update() + def ensure_same_sizes(self): + # HACK This ensures same size while shrinking. + # ReferenceViewer might be 1 pixel shorter in width + # due to the toolbar in the middle keeping the same width, + # so resizing in the GridLayout's engine leads to not enough space + # left for the panel on the right. + # This work as a QMainWindow, but doesn't work as a QDockWidget: + # resize can only grow. Might need some custom sizeHint somewhere... + # self.horizontalLayout.setColumnMinimumWidth( + # 0, self.selectedImageViewer.size().width()) + # self.horizontalLayout.setColumnMinimumWidth( + # 2, self.selectedImageViewer.size().width()) + + # This works when expanding but it's ugly: + if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width(): + # print(f"""Before selected size: {self.selectedImageViewer.size()}\n""", + # f"""Before reference size: {self.referenceImageViewer.size()}""") + self.selectedImageViewer.resize(self.referenceImageViewer.size()) + # print(f"""After selected size: {self.selectedImageViewer.size()}\n""", + # f"""After reference size: {self.referenceImageViewer.size()}""") + # model --> view def refresh(self): DetailsDialogBase.refresh(self) if self.isVisible(): self._update() + + +class EmittingFrame(QFrame): + """Emits a signal whenever is resized""" + resized = pyqtSignal(QResizeEvent) + + def resizeEvent(self, event): + self.resized.emit(event) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 54366e98..08a70db8 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -10,6 +10,7 @@ from PyQt5.QtWidgets import ( QToolBar, QToolButton, QAction, QWidget, QScrollArea, QApplication, QAbstractScrollArea, QStyle) from hscommon.trans import trget +from hscommon.plat import ISLINUX tr = trget("ui") MAX_SCALE = 12.0 @@ -21,7 +22,7 @@ def createActions(actions, target): for name, shortcut, icon, desc, func in actions: action = QAction(target) if icon: - action.setIcon(QIcon.fromTheme(icon)) + action.setIcon(icon) if shortcut: action.setShortcut(shortcut) action.setText(desc) @@ -48,28 +49,32 @@ class ViewerToolBar(QToolBar): ( "actionZoomIn", QKeySequence.ZoomIn, - "zoom-in", + QIcon.fromTheme("zoom-in") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_in")), tr("Increase zoom"), controller.zoomIn, ), ( "actionZoomOut", QKeySequence.ZoomOut, - "zoom-out", + QIcon.fromTheme("zoom-out") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_out")), tr("Decrease zoom"), controller.zoomOut, ), ( "actionNormalSize", tr("Ctrl+/"), - "zoom-original", + QIcon.fromTheme("zoom-original") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_original")), tr("Normal size"), controller.zoomNormalSize, ), ( "actionBestFit", tr("Ctrl+*"), - "zoom-best-fit", + QIcon.fromTheme("zoom-best-fit") if ISLINUX + else QIcon(QPixmap(":/" + "zoom_best_fit")), tr("Best fit"), controller.zoomBestFit, ) @@ -83,7 +88,9 @@ class ViewerToolBar(QToolBar): self.buttonImgSwap.setToolButtonStyle(Qt.ToolButtonIconOnly) self.buttonImgSwap.setIcon( QIcon.fromTheme('view-refresh', - self.style().standardIcon(QStyle.SP_BrowserReload))) + self.style().standardIcon(QStyle.SP_BrowserReload)) + if ISLINUX + else QIcon(QPixmap(":/" + "exchange"))) self.buttonImgSwap.setText('Swap images') self.buttonImgSwap.setToolTip('Swap images') self.buttonImgSwap.pressed.connect(self.controller.swapImages) @@ -142,6 +149,7 @@ class BaseController(QObject): self.bestFit = True self.parent = parent # To change buttons' states self.cached_group = None + self.same_dimensions = True def setupViewers(self, selectedViewer, referenceViewer): self.selectedViewer = selectedViewer @@ -156,6 +164,8 @@ class BaseController(QObject): def updateView(self, ref, dupe, group): # To keep current scale accross dupes from the same group + previous_same_dimensions = self.same_dimensions + self.same_dimensions = True same_group = True if group != self.cached_group: same_group = False @@ -164,6 +174,7 @@ class BaseController(QObject): self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: # currently selected file is the actual reference file + # self.same_dimensions = False self.referencePixmap = QPixmap() self.scaledReferencePixmap = QPixmap() self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) @@ -171,7 +182,10 @@ class BaseController(QObject): else: self.referencePixmap = QPixmap(str(ref.path)) self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + if ref.dimensions != dupe.dimensions: + self.same_dimensions = False self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + self.updateButtonsAsPerDimensions(previous_same_dimensions) self.updateBothImages(same_group) self.centerViews(same_group and self.referencePixmap.isNull()) @@ -207,11 +221,11 @@ class BaseController(QObject): # zoomed in state, expand # only if not same_group, we need full update scaledpixmap = pixmap.scaled( - target_size, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) + target_size, Qt.KeepAspectRatioByExpanding, Qt.FastTransformation) else: # best fit, keep ratio always scaledpixmap = pixmap.scaled( - target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + target_size, Qt.KeepAspectRatio, Qt.FastTransformation) viewer.setImage(scaledpixmap) return target_size @@ -294,6 +308,22 @@ class BaseController(QObject): self.parent.verticalToolBar.buttonNormalSize.setEnabled(round(self.current_scale, 1) != 1.0) self.parent.verticalToolBar.buttonBestFit.setEnabled(self.bestFit is False) + def updateButtonsAsPerDimensions(self, previous_same_dimensions): + if not self.same_dimensions: + self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) + if not self.bestFit: + self.zoomBestFit() + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if not self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + return + if not self.bestFit and not previous_same_dimensions: + self.zoomBestFit() + self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) + @pyqtSlot() def zoomBestFit(self): """Setup before scaling to bestfit""" @@ -320,6 +350,7 @@ class BaseController(QObject): self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) self.parent.verticalToolBar.buttonBestFit.setEnabled(False) + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) def setBestFit(self, value): self.bestFit = value @@ -339,8 +370,12 @@ class BaseController(QObject): self.selectedViewer.scaleToNormalSize() self.referenceViewer.scaleToNormalSize() - self.parent.verticalToolBar.buttonZoomIn.setEnabled(True) - self.parent.verticalToolBar.buttonZoomOut.setEnabled(True) + if self.same_dimensions: + self.parent.verticalToolBar.buttonZoomIn.setEnabled(True) + self.parent.verticalToolBar.buttonZoomOut.setEnabled(True) + else: + # we can't allow swapping pixmaps of different dimensions + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(False) self.parent.verticalToolBar.buttonBestFit.setEnabled(True) @@ -361,8 +396,16 @@ class QWidgetController(BaseController): def __init__(self, parent): super().__init__(parent) + def _updateImage(self, *args): + ret = super()._updateImage(*args) + # Fix alignment when resizing window + self.centerViews() + return ret + @pyqtSlot(QPointF) def onDraggedMouse(self, delta): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer: self.selectedViewer.onDraggedMouse(delta) else: @@ -400,8 +443,14 @@ class ScrollAreaController(BaseController): self.selectedViewer.ignore_signal = True self.referenceViewer.ignore_signal = True - self.selectedViewer.onDraggedMouse(delta) - self.referenceViewer.onDraggedMouse(delta) + if self.same_dimensions: + self.selectedViewer.onDraggedMouse(delta) + self.referenceViewer.onDraggedMouse(delta) + else: + if self.sender() is self.selectedViewer: + self.selectedViewer.onDraggedMouse(delta) + else: + self.referenceViewer.onDraggedMouse(delta) self.selectedViewer.ignore_signal = False self.referenceViewer.ignore_signal = False @@ -422,6 +471,8 @@ class ScrollAreaController(BaseController): @pyqtSlot(int) def onVScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._verticalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._verticalScrollBar.setValue(value) @@ -431,6 +482,8 @@ class ScrollAreaController(BaseController): @pyqtSlot(int) def onHScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._horizontalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._horizontalScrollBar.setValue(value) @@ -448,6 +501,8 @@ class ScrollAreaController(BaseController): def zoomBestFit(self): # Disable scrollbars to avoid GridLayout size rounding glitch super().zoomBestFit() + if self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(False) self.selectedViewer.toggleScrollBars() self.referenceViewer.toggleScrollBars() @@ -484,6 +539,8 @@ class GraphicsViewController(BaseController): @pyqtSlot(int) def onVScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._verticalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._verticalScrollBar.setValue(value) @@ -493,6 +550,8 @@ class GraphicsViewController(BaseController): @pyqtSlot(int) def onHScrollBarChanged(self, value): + if not self.same_dimensions: + return if self.sender() is self.referenceViewer._horizontalScrollBar: if not self.selectedViewer.ignore_signal: self.selectedViewer._horizontalScrollBar.setValue(value) @@ -518,9 +577,16 @@ class GraphicsViewController(BaseController): self.parent.verticalToolBar.buttonZoomOut.setEnabled(False) self.parent.verticalToolBar.buttonZoomIn.setEnabled(False) self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) + if not self.referencePixmap.isNull(): + self.parent.verticalToolBar.buttonImgSwap.setEnabled(True) + # else: + # self.referenceViewer.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + # self.referenceViewer.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) def updateView(self, ref, dupe, group): # Keep current scale accross dupes from the same group + previous_same_dimensions = self.same_dimensions + self.same_dimensions = True same_group = True if group != self.cached_group: same_group = False @@ -529,16 +595,19 @@ class GraphicsViewController(BaseController): self.selectedPixmap = QPixmap(str(dupe.path)) if ref is dupe: # currently selected file is the actual reference file + self.same_dimensions = False 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) + if ref.dimensions != dupe.dimensions: + self.same_dimensions = False self.parent.verticalToolBar.buttonNormalSize.setEnabled(True) - - self.selectedViewer.setImage(self.selectedPixmap) - self.referenceViewer.setImage(self.referencePixmap) + # self.selectedViewer.setImage(self.selectedPixmap) + # self.referenceViewer.setImage(self.referencePixmap) + self.updateButtonsAsPerDimensions(previous_same_dimensions) self.updateBothImages(same_group) def updateBothImages(self, same_group=False): @@ -557,7 +626,9 @@ class GraphicsViewController(BaseController): def _updateFitImage(self, pixmap, viewer): # If not same_group, we need full update""" + viewer.setImage(pixmap) if pixmap.isNull(): + # viewer._item = None return if viewer.bestFit: viewer.fitScale() @@ -717,7 +788,7 @@ class QWidgetImageViewer(QWidget): self.setMouseTracking(False) def wheelEvent(self, event): - if self.bestFit or not self.isEnabled(): + if self.bestFit or not self.controller.same_dimensions or not self.isEnabled(): event.ignore() return @@ -910,7 +981,7 @@ class ScrollAreaImageViewer(QScrollArea): super().mouseReleaseEvent(event) def wheelEvent(self, event): - if self.bestFit: + if self.bestFit or not self.controller.same_dimensions: event.ignore() return oldScale = self.current_scale @@ -1034,7 +1105,7 @@ class ScrollAreaImageViewer(QScrollArea): class GraphicsViewViewer(QGraphicsView): - """Re-Implementation using a more full fledged class.""" + """Re-Implementation a full-fledged GraphicsView but is a bit buggy.""" mouseDragged = pyqtSignal() mouseWheeled = pyqtSignal(float, QPointF) @@ -1163,7 +1234,7 @@ class GraphicsViewViewer(QGraphicsView): self._centerPoint = self.mapToScene(self.rect().center()) def wheelEvent(self, event): - if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE: + if self.bestFit or MIN_SCALE > self.current_scale > MAX_SCALE or not self.controller.same_dimensions: event.ignore() return pointBeforeScale = QPointF(self.mapToScene(self.mapFromGlobal(QCursor.pos()))) @@ -1186,6 +1257,10 @@ class GraphicsViewViewer(QGraphicsView): self.other_viewer.ignore_signal = False def setImage(self, pixmap): + if pixmap.isNull(): + self.ignore_signal = True + elif self.ignore_signal: + self.ignore_signal = False self._pixmap = pixmap self._item.setPixmap(pixmap) self.translate(1, 1) @@ -1241,7 +1316,7 @@ class GraphicsViewViewer(QGraphicsView): """Called when the pixmap is set back to original size.""" self.bestFit = False self.scaleAt(1.0) - self.toggleScrollBars() + self.toggleScrollBars(True) self.update() def adjustScrollBarsScaled(self, delta): diff --git a/qt/preferences.py b/qt/preferences.py index c9691cca..39e55b1e 100644 --- a/qt/preferences.py +++ b/qt/preferences.py @@ -31,6 +31,8 @@ class Preferences(PreferencesBase): self.tableFontSize = get("TableFontSize", self.tableFontSize) self.reference_bold_font = get('ReferenceBoldFont', self.reference_bold_font) + self.details_dialog_titlebar_enabled = get('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) + self.details_dialog_vertical_titlebar = get('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) self.resultWindowIsMaximized = get( "ResultWindowIsMaximized", self.resultWindowIsMaximized ) @@ -67,6 +69,8 @@ class Preferences(PreferencesBase): self.tableFontSize = QApplication.font().pointSize() self.reference_bold_font = True + self.details_dialog_titlebar_enabled = True + self.details_dialog_vertical_titlebar = True self.resultWindowIsMaximized = False self.resultWindowRect = None self.directoriesWindowRect = None @@ -100,6 +104,8 @@ class Preferences(PreferencesBase): set_("TableFontSize", self.tableFontSize) set_('ReferenceBoldFont', self.reference_bold_font) + set_('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) + set_('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) self.set_rect("ResultWindowRect", self.resultWindowRect) self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index eb3462e3..95eb1b67 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -117,8 +117,21 @@ class PreferencesDialogBase(QDialog): self.widgetsVLayout.addLayout( horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None]) ) - self._setupAddCheckbox("reference_bold_font", tr("Bold font for reference.")) + self._setupAddCheckbox("reference_bold_font", + tr("Bold font for reference")) self.widgetsVLayout.addWidget(self.reference_bold_font) + + self._setupAddCheckbox("details_dialog_titlebar_enabled", + tr("Details dialog displays a title bar and is dockable")) + self.widgetsVLayout.addWidget(self.details_dialog_titlebar_enabled) + self._setupAddCheckbox("details_dialog_vertical_titlebar", + tr("Details dialog displays a vertical title bar (Linux only)")) + self.widgetsVLayout.addWidget(self.details_dialog_vertical_titlebar) + self.details_dialog_vertical_titlebar.setEnabled( + self.details_dialog_titlebar_enabled.isChecked()) + self.details_dialog_titlebar_enabled.stateChanged.connect( + self.details_dialog_vertical_titlebar.setEnabled) + self.languageLabel = QLabel(tr("Language:"), self) self.languageComboBox = QComboBox(self) for lang in self.supportedLanguages: @@ -190,6 +203,8 @@ class PreferencesDialogBase(QDialog): setchecked(self.ignoreHardlinkMatches, prefs.ignore_hardlink_matches) setchecked(self.debugModeBox, prefs.debug_mode) setchecked(self.reference_bold_font, prefs.reference_bold_font) + setchecked(self.details_dialog_titlebar_enabled , prefs.details_dialog_titlebar_enabled) + setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) self.customCommandEdit.setText(prefs.custom_command) self.fontSizeSpinBox.setValue(prefs.tableFontSize) @@ -210,6 +225,8 @@ class PreferencesDialogBase(QDialog): prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches) prefs.debug_mode = ischecked(self.debugModeBox) prefs.reference_bold_font = ischecked(self.reference_bold_font) + prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled) + prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar) prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() prefs.custom_command = str(self.customCommandEdit.text()) prefs.tableFontSize = self.fontSizeSpinBox.value() diff --git a/qt/se/details_dialog.py b/qt/se/details_dialog.py index 812c649f..0f922dc4 100644 --- a/qt/se/details_dialog.py +++ b/qt/se/details_dialog.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView +from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QWidget from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -27,3 +27,6 @@ class DetailsDialog(DetailsDialogBase): self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) self.verticalLayout.addWidget(self.tableView) + self.centralWidget = QWidget() + self.centralWidget.setLayout(self.verticalLayout) + self.setWidget(self.centralWidget) diff --git a/qtlib/about_box.py b/qtlib/about_box.py index 88512666..99c3a059 100644 --- a/qtlib/about_box.py +++ b/qtlib/about_box.py @@ -42,7 +42,7 @@ class AboutBox(QDialog): self.setWindowTitle( tr("About {}").format(QCoreApplication.instance().applicationName()) ) - self.resize(400, 190) + self.resize(400, 290) sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) @@ -69,6 +69,21 @@ class AboutBox(QDialog): self.verticalLayout.addWidget(self.label_3) self.label_3.setText(tr("Licensed under GPLv3")) self.label = QLabel(self) + self.label_4 = QLabel(self) + self.label_4.setWordWrap(True) + self.label_4.setTextFormat(Qt.RichText) + self.label_4.setOpenExternalLinks(True) + self.label_4.setText(tr( + """Exchange icon + made by Jason Cho (used with permission). +
+Zoom In +Zoom Out +Zoomt Best Fit +Zoom Original + icons made by schollidesign + (licensed under GPL).""")) + self.verticalLayout.addWidget(self.label_4) font = QFont() font.setWeight(75) font.setBold(True) From 35392634373ee7327178746131221134410598d0 Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 20 Jul 2020 03:10:06 +0200 Subject: [PATCH 34/61] Add tabs to preference dialog. --- qt/preferences_dialog.py | 77 +++++++++++++++++++++++++--------------- 1 file changed, 48 insertions(+), 29 deletions(-) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 95eb1b67..2d9798a0 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -20,6 +20,8 @@ from PyQt5.QtWidgets import ( QMessageBox, QSpinBox, QLayout, + QTabWidget, + QWidget, ) from hscommon.trans import trget @@ -111,34 +113,7 @@ class PreferencesDialogBase(QDialog): def _setupBottomPart(self): # The bottom part of the pref panel is always the same in all editions. - self.fontSizeLabel = QLabel(tr("Font size:")) - self.fontSizeSpinBox = QSpinBox() - self.fontSizeSpinBox.setMinimum(5) - self.widgetsVLayout.addLayout( - horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None]) - ) - self._setupAddCheckbox("reference_bold_font", - tr("Bold font for reference")) - self.widgetsVLayout.addWidget(self.reference_bold_font) - - self._setupAddCheckbox("details_dialog_titlebar_enabled", - tr("Details dialog displays a title bar and is dockable")) - self.widgetsVLayout.addWidget(self.details_dialog_titlebar_enabled) - self._setupAddCheckbox("details_dialog_vertical_titlebar", - tr("Details dialog displays a vertical title bar (Linux only)")) - self.widgetsVLayout.addWidget(self.details_dialog_vertical_titlebar) - self.details_dialog_vertical_titlebar.setEnabled( - self.details_dialog_titlebar_enabled.isChecked()) - self.details_dialog_titlebar_enabled.stateChanged.connect( - self.details_dialog_vertical_titlebar.setEnabled) - - self.languageLabel = QLabel(tr("Language:"), self) - self.languageComboBox = QComboBox(self) - for lang in self.supportedLanguages: - self.languageComboBox.addItem(get_langnames()[lang]) - self.widgetsVLayout.addLayout( - horizontalWrap([self.languageLabel, self.languageComboBox, None]) - ) + self._setupDisplayPage() self.copyMoveLabel = QLabel(self) self.copyMoveLabel.setText(tr("Copy and Move:")) self.widgetsVLayout.addWidget(self.copyMoveLabel) @@ -155,6 +130,40 @@ class PreferencesDialogBase(QDialog): self.customCommandEdit = QLineEdit(self) self.widgetsVLayout.addWidget(self.customCommandEdit) + def _setupDisplayPage(self): + self.fontSizeLabel = QLabel(tr("Font size:")) + self.fontSizeSpinBox = QSpinBox() + self.fontSizeSpinBox.setMinimum(5) + self.displayVLayout.addLayout( + horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None]) + ) + self._setupAddCheckbox("reference_bold_font", + tr("Bold font for reference")) + self.displayVLayout.addWidget(self.reference_bold_font) + + self.details_dialog_label = QLabel(tr("Details window:")) + self.displayVLayout.addWidget(self.details_dialog_label) + self._setupAddCheckbox("details_dialog_titlebar_enabled", + tr("Show a title bar and is dockable")) + self.details_dialog_titlebar_enabled.setToolTip( + tr("Title bar can only be disabled while the window is docked")) + self.displayVLayout.addWidget(self.details_dialog_titlebar_enabled) + self._setupAddCheckbox("details_dialog_vertical_titlebar", + tr("Vertical title bar")) + self.displayVLayout.addWidget(self.details_dialog_vertical_titlebar) + self.details_dialog_vertical_titlebar.setEnabled( + self.details_dialog_titlebar_enabled.isChecked()) + self.details_dialog_titlebar_enabled.stateChanged.connect( + self.details_dialog_vertical_titlebar.setEnabled) + + self.languageLabel = QLabel(tr("Language:"), self) + self.languageComboBox = QComboBox(self) + for lang in self.supportedLanguages: + self.languageComboBox.addItem(get_langnames()[lang]) + self.displayVLayout.insertLayout( + 0, horizontalWrap([self.languageLabel, self.languageComboBox, None]) + ) + def _setupAddCheckbox(self, name, label, parent=None): if parent is None: parent = self @@ -171,17 +180,27 @@ class PreferencesDialogBase(QDialog): self.setSizeGripEnabled(False) self.setModal(True) self.mainVLayout = QVBoxLayout(self) + self.tabwidget = QTabWidget() + self.page_general = QWidget() + self.page_display = QWidget() self.widgetsVLayout = QVBoxLayout() + self.page_general.setLayout(self.widgetsVLayout) + self.displayVLayout = QVBoxLayout() + self.page_display.setLayout(self.displayVLayout) self._setupPreferenceWidgets() - self.mainVLayout.addLayout(self.widgetsVLayout) + # self.mainVLayout.addLayout(self.widgetsVLayout) self.buttonBox = QDialogButtonBox(self) self.buttonBox.setStandardButtons( QDialogButtonBox.Cancel | QDialogButtonBox.Ok | QDialogButtonBox.RestoreDefaults ) + self.mainVLayout.addWidget(self.tabwidget) self.mainVLayout.addWidget(self.buttonBox) self.layout().setSizeConstraint(QLayout.SetFixedSize) + self.tabwidget.addTab(self.page_general, "General") + self.tabwidget.addTab(self.page_display, "Display") + self.displayVLayout.addStretch(0) def _load(self, prefs, setchecked): # Edition-specific From 298f659f6ec23fdb0c957dab4ee5b88389a4eae8 Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 20 Jul 2020 05:04:25 +0200 Subject: [PATCH 35/61] Fix Restore Default Preferences button * When clicking the "Restore Default" in the preferences dialog, only affect the preferences displayed in the current tab. The hidden tab should not be affected by this button. --- qt/me/preferences_dialog.py | 2 +- qt/pe/preferences_dialog.py | 2 +- qt/preferences_dialog.py | 64 +++++++++++++++++++++++-------------- qt/se/preferences_dialog.py | 2 +- 4 files changed, 43 insertions(+), 27 deletions(-) diff --git a/qt/me/preferences_dialog.py b/qt/me/preferences_dialog.py index 4dd8b455..462e5682 100644 --- a/qt/me/preferences_dialog.py +++ b/qt/me/preferences_dialog.py @@ -76,7 +76,7 @@ class PreferencesDialog(PreferencesDialogBase): self.widgetsVLayout.addWidget(self.debugModeBox) self._setupBottomPart() - def _load(self, prefs, setchecked): + def _load(self, prefs, setchecked, section): setchecked(self.tagTrackBox, prefs.scan_tag_track) setchecked(self.tagArtistBox, prefs.scan_tag_artist) setchecked(self.tagAlbumBox, prefs.scan_tag_album) diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py index c220d317..05626582 100644 --- a/qt/pe/preferences_dialog.py +++ b/qt/pe/preferences_dialog.py @@ -45,7 +45,7 @@ class PreferencesDialog(PreferencesDialogBase): self.widgetsVLayout.addWidget(self.cacheTypeRadio) self._setupBottomPart() - def _load(self, prefs, setchecked): + def _load(self, prefs, setchecked, section): setchecked(self.matchScaledBox, prefs.match_scaled) self.cacheTypeRadio.selected_index = ( 1 if prefs.picture_cache_type == "shelve" else 0 diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 2d9798a0..6ade467c 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -27,6 +27,7 @@ from PyQt5.QtWidgets import ( from hscommon.trans import trget from qtlib.util import horizontalWrap from qtlib.preferences import get_langnames +from enum import Flag, auto from .preferences import Preferences @@ -52,6 +53,13 @@ SUPPORTED_LANGUAGES = [ ] +class Sections(Flag): + """Filter blocks of preferences when reset or loaded""" + GENERAL = auto() + DISPLAY = auto() + ALL = GENERAL | DISPLAY + + class PreferencesDialogBase(QDialog): def __init__(self, parent, app, **kwargs): flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint @@ -202,7 +210,7 @@ class PreferencesDialogBase(QDialog): self.tabwidget.addTab(self.page_display, "Display") self.displayVLayout.addStretch(0) - def _load(self, prefs, setchecked): + def _load(self, prefs, setchecked, section): # Edition-specific pass @@ -210,29 +218,31 @@ class PreferencesDialogBase(QDialog): # Edition-specific pass - def load(self, prefs=None): + def load(self, prefs=None, section=Sections.ALL): if prefs is None: prefs = self.app.prefs - self.filterHardnessSlider.setValue(prefs.filter_hardness) - self.filterHardnessLabel.setNum(prefs.filter_hardness) setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked) - setchecked(self.mixFileKindBox, prefs.mix_file_kind) - setchecked(self.useRegexpBox, prefs.use_regexp) - setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders) - setchecked(self.ignoreHardlinkMatches, prefs.ignore_hardlink_matches) - setchecked(self.debugModeBox, prefs.debug_mode) - setchecked(self.reference_bold_font, prefs.reference_bold_font) - setchecked(self.details_dialog_titlebar_enabled , prefs.details_dialog_titlebar_enabled) - setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) - self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) - self.customCommandEdit.setText(prefs.custom_command) - self.fontSizeSpinBox.setValue(prefs.tableFontSize) - try: - langindex = self.supportedLanguages.index(self.app.prefs.language) - except ValueError: - langindex = 0 - self.languageComboBox.setCurrentIndex(langindex) - self._load(prefs, setchecked) + if section & Sections.GENERAL: + self.filterHardnessSlider.setValue(prefs.filter_hardness) + self.filterHardnessLabel.setNum(prefs.filter_hardness) + setchecked(self.mixFileKindBox, prefs.mix_file_kind) + setchecked(self.useRegexpBox, prefs.use_regexp) + setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders) + setchecked(self.ignoreHardlinkMatches, prefs.ignore_hardlink_matches) + setchecked(self.debugModeBox, prefs.debug_mode) + self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type) + self.customCommandEdit.setText(prefs.custom_command) + if section & Sections.DISPLAY: + setchecked(self.reference_bold_font, prefs.reference_bold_font) + setchecked(self.details_dialog_titlebar_enabled , prefs.details_dialog_titlebar_enabled) + setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) + self.fontSizeSpinBox.setValue(prefs.tableFontSize) + try: + langindex = self.supportedLanguages.index(self.app.prefs.language) + except ValueError: + langindex = 0 + self.languageComboBox.setCurrentIndex(langindex) + self._load(prefs, setchecked, section) def save(self): prefs = self.app.prefs @@ -262,11 +272,17 @@ class PreferencesDialogBase(QDialog): self.app.prefs.language = lang self._save(prefs, ischecked) - def resetToDefaults(self): - self.load(Preferences()) + def resetToDefaults(self, section_to_update): + self.load(Preferences(), section_to_update) # --- Events def buttonClicked(self, button): role = self.buttonBox.buttonRole(button) if role == QDialogButtonBox.ResetRole: - self.resetToDefaults() + current_tab = self.tabwidget.currentWidget() + section_to_update = Sections.ALL + if current_tab is self.page_general: + section_to_update = Sections.GENERAL + if current_tab is self.page_display: + section_to_update = Sections.DISPLAY + self.resetToDefaults(section_to_update) diff --git a/qt/se/preferences_dialog.py b/qt/se/preferences_dialog.py index 994e8b54..0424afcc 100644 --- a/qt/se/preferences_dialog.py +++ b/qt/se/preferences_dialog.py @@ -85,7 +85,7 @@ class PreferencesDialog(PreferencesDialogBase): self.widgetsVLayout.addWidget(self.widget) self._setupBottomPart() - def _load(self, prefs, setchecked): + def _load(self, prefs, setchecked, section): setchecked(self.matchSimilarBox, prefs.match_similar) setchecked(self.wordWeightingBox, prefs.word_weighting) setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files) From cf645650129cab212ae01667b2969c95745035cd Mon Sep 17 00:00:00 2001 From: glubsy Date: Tue, 21 Jul 2020 03:52:15 +0200 Subject: [PATCH 36/61] Add option to use internal icons in details dialog * On Windows and MacOS, no idea how themes work so only allow Linux to use their theme icons * Internal icons are used by default on non-Linux platforms --- qt/pe/image_viewer.py | 17 +++++++++++++---- qt/preferences.py | 14 ++++++++++++-- qt/preferences_dialog.py | 10 ++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 08a70db8..bf71a068 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -49,7 +49,9 @@ class ViewerToolBar(QToolBar): ( "actionZoomIn", QKeySequence.ZoomIn, - QIcon.fromTheme("zoom-in") if ISLINUX + QIcon.fromTheme("zoom-in") + if ISLINUX + and not self.parent.app.prefs.details_dialog_override_theme_icons else QIcon(QPixmap(":/" + "zoom_in")), tr("Increase zoom"), controller.zoomIn, @@ -57,7 +59,9 @@ class ViewerToolBar(QToolBar): ( "actionZoomOut", QKeySequence.ZoomOut, - QIcon.fromTheme("zoom-out") if ISLINUX + QIcon.fromTheme("zoom-out") + if ISLINUX + and not self.parent.app.prefs.details_dialog_override_theme_icons else QIcon(QPixmap(":/" + "zoom_out")), tr("Decrease zoom"), controller.zoomOut, @@ -65,7 +69,9 @@ class ViewerToolBar(QToolBar): ( "actionNormalSize", tr("Ctrl+/"), - QIcon.fromTheme("zoom-original") if ISLINUX + QIcon.fromTheme("zoom-original") + if ISLINUX + and not self.parent.app.prefs.details_dialog_override_theme_icons else QIcon(QPixmap(":/" + "zoom_original")), tr("Normal size"), controller.zoomNormalSize, @@ -73,7 +79,9 @@ class ViewerToolBar(QToolBar): ( "actionBestFit", tr("Ctrl+*"), - QIcon.fromTheme("zoom-best-fit") if ISLINUX + QIcon.fromTheme("zoom-best-fit") + if ISLINUX + and not self.parent.app.prefs.details_dialog_override_theme_icons else QIcon(QPixmap(":/" + "zoom_best_fit")), tr("Best fit"), controller.zoomBestFit, @@ -90,6 +98,7 @@ class ViewerToolBar(QToolBar): QIcon.fromTheme('view-refresh', self.style().standardIcon(QStyle.SP_BrowserReload)) if ISLINUX + and not self.parent.app.prefs.details_dialog_override_theme_icons else QIcon(QPixmap(":/" + "exchange"))) self.buttonImgSwap.setText('Swap images') self.buttonImgSwap.setToolTip('Swap images') diff --git a/qt/preferences.py b/qt/preferences.py index 39e55b1e..3d00b42b 100644 --- a/qt/preferences.py +++ b/qt/preferences.py @@ -7,6 +7,7 @@ from PyQt5.QtWidgets import QApplication from hscommon import trans +from hscommon.plat import ISLINUX from core.app import AppMode from core.scanner import ScanType from qtlib.preferences import Preferences as PreferencesBase @@ -31,8 +32,14 @@ class Preferences(PreferencesBase): self.tableFontSize = get("TableFontSize", self.tableFontSize) self.reference_bold_font = get('ReferenceBoldFont', self.reference_bold_font) - self.details_dialog_titlebar_enabled = get('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) - self.details_dialog_vertical_titlebar = get('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) + self.details_dialog_titlebar_enabled = get('DetailsDialogTitleBarEnabled', + self.details_dialog_titlebar_enabled) + self.details_dialog_vertical_titlebar = get('DetailsDialogVerticalTitleBar', + self.details_dialog_vertical_titlebar) + # On Windows and MacOS, use internal icons by default + self.details_dialog_override_theme_icons =\ + get('DetailsDialogOverrideThemeIcons', + self.details_dialog_override_theme_icons) if ISLINUX else True self.resultWindowIsMaximized = get( "ResultWindowIsMaximized", self.resultWindowIsMaximized ) @@ -71,6 +78,8 @@ class Preferences(PreferencesBase): self.reference_bold_font = True self.details_dialog_titlebar_enabled = True self.details_dialog_vertical_titlebar = True + # By default use internal icons on platforms other than Linux for now + self.details_dialog_override_theme_icons = False if not ISLINUX else True self.resultWindowIsMaximized = False self.resultWindowRect = None self.directoriesWindowRect = None @@ -106,6 +115,7 @@ class Preferences(PreferencesBase): set_('ReferenceBoldFont', self.reference_bold_font) set_('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) set_('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) + set_('DetailsDialogOverrideThemeIcons', self.details_dialog_override_theme_icons) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) self.set_rect("ResultWindowRect", self.resultWindowRect) self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 6ade467c..f074f986 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -25,6 +25,7 @@ from PyQt5.QtWidgets import ( ) from hscommon.trans import trget +from hscommon.plat import ISLINUX from qtlib.util import horizontalWrap from qtlib.preferences import get_langnames from enum import Flag, auto @@ -163,6 +164,13 @@ class PreferencesDialogBase(QDialog): self.details_dialog_titlebar_enabled.isChecked()) self.details_dialog_titlebar_enabled.stateChanged.connect( self.details_dialog_vertical_titlebar.setEnabled) + self._setupAddCheckbox("details_dialog_override_theme_icons", + tr("Override theme icons")) + self.details_dialog_override_theme_icons.setToolTip( + tr("Use our own internal icons instead of those provided by theme engine")) + # Prevent changing this on platforms where themes are unpredictable + self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True) + self.displayVLayout.addWidget(self.details_dialog_override_theme_icons) self.languageLabel = QLabel(tr("Language:"), self) self.languageComboBox = QComboBox(self) @@ -237,6 +245,7 @@ class PreferencesDialogBase(QDialog): setchecked(self.details_dialog_titlebar_enabled , prefs.details_dialog_titlebar_enabled) setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) self.fontSizeSpinBox.setValue(prefs.tableFontSize) + setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons) try: langindex = self.supportedLanguages.index(self.app.prefs.language) except ValueError: @@ -256,6 +265,7 @@ class PreferencesDialogBase(QDialog): prefs.reference_bold_font = ischecked(self.reference_bold_font) prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled) prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar) + prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() prefs.custom_command = str(self.customCommandEdit.text()) prefs.tableFontSize = self.fontSizeSpinBox.value() From 9ae0d7e5cf8f4f8a13248f2b9db2b6db3fb02ddb Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 22 Jul 2020 21:38:03 +0200 Subject: [PATCH 37/61] Add color picker buttons to preferences dialog * Buttons display the color currently in use * Result table uses selected colors accordingly * Keep items aligned with GridLayouts in preference dialog * Reordering of items in a more logical manner* --- qt/preferences.py | 12 +++++ qt/preferences_dialog.py | 95 +++++++++++++++++++++++++++++++++------- qt/results_model.py | 6 +-- 3 files changed, 93 insertions(+), 20 deletions(-) diff --git a/qt/preferences.py b/qt/preferences.py index 3d00b42b..604a9107 100644 --- a/qt/preferences.py +++ b/qt/preferences.py @@ -5,6 +5,8 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor from hscommon import trans from hscommon.plat import ISLINUX @@ -40,6 +42,12 @@ class Preferences(PreferencesBase): self.details_dialog_override_theme_icons =\ get('DetailsDialogOverrideThemeIcons', self.details_dialog_override_theme_icons) if ISLINUX else True + + self.result_table_ref_foreground_color =\ + get('ResultTableRefForegroundColor', self.result_table_ref_foreground_color) + self.result_table_delta_foreground_color =\ + get('ResultTableDeltaForegroundColor', self.result_table_delta_foreground_color) + self.resultWindowIsMaximized = get( "ResultWindowIsMaximized", self.resultWindowIsMaximized ) @@ -80,6 +88,8 @@ class Preferences(PreferencesBase): self.details_dialog_vertical_titlebar = True # By default use internal icons on platforms other than Linux for now self.details_dialog_override_theme_icons = False if not ISLINUX else True + self.result_table_ref_foreground_color = QColor(Qt.blue) + self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange self.resultWindowIsMaximized = False self.resultWindowRect = None self.directoriesWindowRect = None @@ -116,6 +126,8 @@ class Preferences(PreferencesBase): set_('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) set_('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) set_('DetailsDialogOverrideThemeIcons', self.details_dialog_override_theme_icons) + set_('ResultTableRefForegroundColor', self.result_table_ref_foreground_color) + set_('ResultTableDeltaForegroundColor', self.result_table_delta_foreground_color) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) self.set_rect("ResultWindowRect", self.resultWindowRect) self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index f074f986..f7dac6e5 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -4,12 +4,13 @@ # 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 Qt, QSize +from PyQt5.QtCore import Qt, QSize, pyqtSlot, pyqtSignal from PyQt5.QtWidgets import ( QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, + QGridLayout, QLabel, QComboBox, QSlider, @@ -22,7 +23,11 @@ from PyQt5.QtWidgets import ( QLayout, QTabWidget, QWidget, + QColorDialog, + QPushButton, + QFrame, ) +from PyQt5.QtGui import QPixmap, QColor, QIcon from hscommon.trans import trget from hscommon.plat import ISLINUX @@ -140,15 +145,42 @@ class PreferencesDialogBase(QDialog): self.widgetsVLayout.addWidget(self.customCommandEdit) def _setupDisplayPage(self): + self.languageLabel = QLabel(tr("Language:"), self) + self.languageComboBox = QComboBox(self) + for lang in self.supportedLanguages: + self.languageComboBox.addItem(get_langnames()[lang]) + self.displayVLayout.insertLayout( + 0, horizontalWrap([self.languageLabel, self.languageComboBox, None]) + ) + + line = QFrame(self) + line.setFrameShape(QFrame.HLine) + self.displayVLayout.addWidget(line) + + gridlayout = QGridLayout() + self.result_table_label = QLabel(tr("Result Table:")) + gridlayout.addWidget(self.result_table_label, 0, 0) self.fontSizeLabel = QLabel(tr("Font size:")) self.fontSizeSpinBox = QSpinBox() self.fontSizeSpinBox.setMinimum(5) - self.displayVLayout.addLayout( - horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None]) - ) + gridlayout.addWidget(self.fontSizeLabel, 1, 0) + gridlayout.addWidget(self.fontSizeSpinBox, 1, 1, 1, 1, Qt.AlignLeft) self._setupAddCheckbox("reference_bold_font", tr("Bold font for reference")) - self.displayVLayout.addWidget(self.reference_bold_font) + gridlayout.addWidget(self.reference_bold_font, 2, 0) + self.result_table_ref_foreground_color_label = QLabel(tr("Reference foreground color:")) + gridlayout.addWidget(self.result_table_ref_foreground_color_label, 3, 0) + self.result_table_ref_foreground_color = ColorPickerButton(self) + gridlayout.addWidget(self.result_table_ref_foreground_color, 3, 1, 1, 1, Qt.AlignLeft) + self.result_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:")) + gridlayout.addWidget(self.result_table_delta_foreground_color_label, 4, 0) + self.result_table_delta_foreground_color = ColorPickerButton(self) + gridlayout.addWidget(self.result_table_delta_foreground_color, 4, 1, 1, 1, Qt.AlignLeft) + self.displayVLayout.addLayout(gridlayout) + + line = QFrame(self) + line.setFrameShape(QFrame.HLine) + self.displayVLayout.addWidget(line) self.details_dialog_label = QLabel(tr("Details window:")) self.displayVLayout.addWidget(self.details_dialog_label) @@ -167,19 +199,11 @@ class PreferencesDialogBase(QDialog): self._setupAddCheckbox("details_dialog_override_theme_icons", tr("Override theme icons")) self.details_dialog_override_theme_icons.setToolTip( - tr("Use our own internal icons instead of those provided by theme engine")) + tr("Use our own internal icons instead of those provided by the theme engine")) # Prevent changing this on platforms where themes are unpredictable self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True) self.displayVLayout.addWidget(self.details_dialog_override_theme_icons) - self.languageLabel = QLabel(tr("Language:"), self) - self.languageComboBox = QComboBox(self) - for lang in self.supportedLanguages: - self.languageComboBox.addItem(get_langnames()[lang]) - self.displayVLayout.insertLayout( - 0, horizontalWrap([self.languageLabel, self.languageComboBox, None]) - ) - def _setupAddCheckbox(self, name, label, parent=None): if parent is None: parent = self @@ -242,10 +266,17 @@ class PreferencesDialogBase(QDialog): self.customCommandEdit.setText(prefs.custom_command) if section & Sections.DISPLAY: setchecked(self.reference_bold_font, prefs.reference_bold_font) - setchecked(self.details_dialog_titlebar_enabled , prefs.details_dialog_titlebar_enabled) - setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) + setchecked(self.details_dialog_titlebar_enabled, + prefs.details_dialog_titlebar_enabled) + setchecked(self.details_dialog_vertical_titlebar, + prefs.details_dialog_vertical_titlebar) self.fontSizeSpinBox.setValue(prefs.tableFontSize) - setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons) + setchecked(self.details_dialog_override_theme_icons, + prefs.details_dialog_override_theme_icons) + self.result_table_ref_foreground_color.setColor( + prefs.result_table_ref_foreground_color) + self.result_table_delta_foreground_color.setColor( + prefs.result_table_delta_foreground_color) try: langindex = self.supportedLanguages.index(self.app.prefs.language) except ValueError: @@ -266,6 +297,8 @@ class PreferencesDialogBase(QDialog): prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled) prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar) prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) + prefs.result_table_ref_foreground_color = self.result_table_ref_foreground_color.color + prefs.result_table_delta_foreground_color = self.result_table_delta_foreground_color.color prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() prefs.custom_command = str(self.customCommandEdit.text()) prefs.tableFontSize = self.fontSizeSpinBox.value() @@ -296,3 +329,31 @@ class PreferencesDialogBase(QDialog): if current_tab is self.page_display: section_to_update = Sections.DISPLAY self.resetToDefaults(section_to_update) + + +class ColorPickerButton(QPushButton): + def __init__(self, parent): + super().__init__(parent) + self.parent = parent + self.color = None + self.clicked.connect(self.onClicked) + + @pyqtSlot() + def onClicked(self): + color = QColorDialog.getColor( + self.color if self.color is not None else Qt.white, + self.parent) + self.setColor(color) + + def setColor(self, color): + size = QSize(1, 1) + px = QPixmap(size) + if color is None: + size.width = 0 + size.height = 0 + elif not color.isValid(): + return + else: + self.color = color + px.fill(color) + self.setIcon(QIcon(px)) diff --git a/qt/results_model.py b/qt/results_model.py index d16fcc97..b896bc92 100644 --- a/qt/results_model.py +++ b/qt/results_model.py @@ -7,7 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex -from PyQt5.QtGui import QBrush, QFont, QFontMetrics, QColor +from PyQt5.QtGui import QBrush, QFont, QFontMetrics from PyQt5.QtWidgets import QTableView from qtlib.table import Table @@ -37,9 +37,9 @@ class ResultsModel(Table): return data[column.name] elif role == Qt.ForegroundRole: if row.isref: - return QBrush(Qt.blue) + return QBrush(self.prefs.result_table_ref_foreground_color) elif row.is_cell_delta(column.name): - return QBrush(QColor(255, 142, 40)) # orange + return QBrush(self.prefs.result_table_delta_foreground_color) elif role == Qt.FontRole: font = QFont(self.view.font()) if self.prefs.reference_bold_font: From 1823575af481747feb5f9c715b8fb7c2ac0d2987 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 29 Jul 2020 04:26:40 +0200 Subject: [PATCH 38/61] Fix swapping table view columns We now have only two columns to swap, not 3. --- qt/pe/image_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index bf71a068..d5b948f8 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -397,7 +397,7 @@ class BaseController(QObject): @pyqtSlot() def swapImages(self): # swap the columns in the details table as well - self.parent.tableView.horizontalHeader().swapSections(1, 2) + self.parent.tableView.horizontalHeader().swapSections(0, 1) class QWidgetController(BaseController): From 1937120ad7b31b5d2d7e0f36b6d1842222c3312c Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 29 Jul 2020 04:51:03 +0200 Subject: [PATCH 39/61] Fix toggling details view via menu or shortcut * Using Ctrl+I would toggle the title bar on/off --- qt/details_dialog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 7b3f07d5..525e2328 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -37,8 +37,9 @@ class DetailsDialog(QDockWidget): def show(self): self._shown_once = True - super().show() - self.update_options() + if not self.isVisible(): + super().show() + self.update_options() def update_options(self): # This disables the title bar (if we had not set one before already) From 9795f141761be40af061be354782258a87d79561 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 29 Jul 2020 20:00:27 +0200 Subject: [PATCH 40/61] Fix title bar toggling on/off when dialog --- qt/details_dialog.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 525e2328..c186e122 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -37,23 +37,23 @@ class DetailsDialog(QDockWidget): def show(self): self._shown_once = True - if not self.isVisible(): - super().show() - self.update_options() + super().show() + self.update_options() def update_options(self): # This disables the title bar (if we had not set one before already) # essentially making it a simple floating window, not dockable anymore - if not self.app.prefs.details_dialog_titlebar_enabled \ - and not self.titleBarWidget(): + if not self.app.prefs.details_dialog_titlebar_enabled: + if not self.titleBarWidget(): # default title bar + self.setTitleBarWidget(QWidget()) # disables title bar + # Windows (and MacOS?) users cannot move a floating window which + # has not native decoration so we force it to dock for now + if not ISLINUX: + self.setFloating(False) + elif self.titleBarWidget() is not None: # title bar is disabled + self.setTitleBarWidget(None) # resets to the default title bar + elif not self.titleBarWidget() and not self.app.prefs.details_dialog_titlebar_enabled: self.setTitleBarWidget(QWidget()) - # Windows (and MacOS?) users cannot move a floating window which - # has not native decoration so we force it to dock for now - if not ISLINUX: - self.setFloating(False) - elif self.titleBarWidget() is not None: - # resets to the default title bar - self.setTitleBarWidget(None) features = self.features() if self.app.prefs.details_dialog_vertical_titlebar: From da8c493c9fe9947cb5a357179503ca6ae4990250 Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 29 Jul 2020 20:43:18 +0200 Subject: [PATCH 41/61] Toggle visibility of details dialog * When using the Ctrl+I shortcut or the "Details" button in the Results window, toggle the details dialog on/off. * This works also while it is docked. --- qt/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/qt/app.py b/qt/app.py index 1749c2f8..7401fe82 100644 --- a/qt/app.py +++ b/qt/app.py @@ -190,7 +190,10 @@ class DupeGuru(QObject): def show_details(self): if self.details_dialog is not None: - self.details_dialog.show() + if not self.details_dialog.isVisible(): + self.details_dialog.show() + else: + self.details_dialog.hide() def showResultsWindow(self): if self.resultWindow is not None: From eab5003e611966b4b826c244f1d07db38d521edf Mon Sep 17 00:00:00 2001 From: glubsy Date: Wed, 29 Jul 2020 21:42:44 +0200 Subject: [PATCH 42/61] Add color preference for delta in details table --- qt/details_dialog.py | 2 +- qt/details_table.py | 7 ++++--- qt/preferences.py | 4 ++++ qt/preferences_dialog.py | 9 +++++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/qt/details_dialog.py b/qt/details_dialog.py index c186e122..517f8511 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -25,7 +25,7 @@ class DetailsDialog(QDockWidget): # has been shown. If it has, we know that our geometry should be saved. self._shown_once = False self.app.prefs.restoreGeometry("DetailsWindowRect", self) - self.tableModel = DetailsModel(self.model) + self.tableModel = DetailsModel(self.model, app) # tableView is defined in subclasses self.tableView.setModel(self.tableModel) self.model.view = self diff --git a/qt/details_table.py b/qt/details_table.py index c42aa0a6..c2a47874 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -8,7 +8,7 @@ from PyQt5.QtCore import Qt, QAbstractTableModel from PyQt5.QtWidgets import QHeaderView, QTableView -from PyQt5.QtGui import QFont, QBrush, QColor +from PyQt5.QtGui import QFont, QBrush from hscommon.trans import trget @@ -18,9 +18,10 @@ HEADER = [tr("Selected"), tr("Reference")] class DetailsModel(QAbstractTableModel): - def __init__(self, model, **kwargs): + def __init__(self, model, app, **kwargs): super().__init__(**kwargs) self.model = model + self.prefs = app.prefs def columnCount(self, parent): return len(HEADER) @@ -43,7 +44,7 @@ class DetailsModel(QAbstractTableModel): if role == Qt.DisplayRole: return self.model.row(row)[column] if role == Qt.ForegroundRole and self.model.row(row)[1] != self.model.row(row)[2]: - return QBrush(QColor(250, 20, 20)) # red + return QBrush(self.prefs.details_table_delta_foreground_color) if role == Qt.FontRole and self.model.row(row)[1] != self.model.row(row)[2]: font = QFont(self.model.view.font()) # or simply QFont() font.setBold(True) diff --git a/qt/preferences.py b/qt/preferences.py index 604a9107..e54550df 100644 --- a/qt/preferences.py +++ b/qt/preferences.py @@ -42,6 +42,8 @@ class Preferences(PreferencesBase): self.details_dialog_override_theme_icons =\ get('DetailsDialogOverrideThemeIcons', self.details_dialog_override_theme_icons) if ISLINUX else True + self.details_table_delta_foreground_color =\ + get('DetailsTableDeltaForegroundColor', self.details_table_delta_foreground_color) self.result_table_ref_foreground_color =\ get('ResultTableRefForegroundColor', self.result_table_ref_foreground_color) @@ -86,6 +88,7 @@ class Preferences(PreferencesBase): self.reference_bold_font = True self.details_dialog_titlebar_enabled = True self.details_dialog_vertical_titlebar = True + self.details_table_delta_foreground_color = QColor(250, 20, 20) # red # By default use internal icons on platforms other than Linux for now self.details_dialog_override_theme_icons = False if not ISLINUX else True self.result_table_ref_foreground_color = QColor(Qt.blue) @@ -126,6 +129,7 @@ class Preferences(PreferencesBase): set_('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) set_('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) set_('DetailsDialogOverrideThemeIcons', self.details_dialog_override_theme_icons) + set_('DetailsTableDeltaForegroundColor', self.details_table_delta_foreground_color) set_('ResultTableRefForegroundColor', self.result_table_ref_foreground_color) set_('ResultTableDeltaForegroundColor', self.result_table_delta_foreground_color) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 37736c96..b52a407f 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -203,6 +203,12 @@ class PreferencesDialogBase(QDialog): # Prevent changing this on platforms where themes are unpredictable self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True) self.displayVLayout.addWidget(self.details_dialog_override_theme_icons) + gridlayout = QGridLayout() + self.details_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:")) + gridlayout.addWidget(self.details_table_delta_foreground_color_label, 4, 0) + self.details_table_delta_foreground_color = ColorPickerButton(self) + gridlayout.addWidget(self.details_table_delta_foreground_color, 4, 1, 1, 1, Qt.AlignLeft) + self.displayVLayout.addLayout(gridlayout) def _setupAddCheckbox(self, name, label, parent=None): if parent is None: @@ -273,6 +279,8 @@ class PreferencesDialogBase(QDialog): self.fontSizeSpinBox.setValue(prefs.tableFontSize) setchecked(self.details_dialog_override_theme_icons, prefs.details_dialog_override_theme_icons) + self.details_table_delta_foreground_color.setColor( + prefs.details_table_delta_foreground_color) self.result_table_ref_foreground_color.setColor( prefs.result_table_ref_foreground_color) self.result_table_delta_foreground_color.setColor( @@ -297,6 +305,7 @@ class PreferencesDialogBase(QDialog): prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled) prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar) prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) + prefs.details_table_delta_foreground_color = self.details_table_delta_foreground_color.color prefs.result_table_ref_foreground_color = self.result_table_ref_foreground_color.color prefs.result_table_delta_foreground_color = self.result_table_delta_foreground_color.color prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex() From fa54e932364faba0e66a215cef2c98f5c9502da5 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 30 Jul 2020 03:13:58 +0200 Subject: [PATCH 43/61] Add preference to turn off scrollbars in viewers Refactor preference Display page to only include PE specific preferences in the PE mode. --- qt/pe/details_dialog.py | 1 + qt/pe/image_viewer.py | 14 ++++++++------ qt/pe/preferences_dialog.py | 29 +++++++++++++++++++++++++++++ qt/preferences.py | 32 ++++++++++++++++++-------------- qt/preferences_dialog.py | 20 +++++++------------- 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/qt/pe/details_dialog.py b/qt/pe/details_dialog.py index d96dba17..c83ee754 100644 --- a/qt/pe/details_dialog.py +++ b/qt/pe/details_dialog.py @@ -20,6 +20,7 @@ tr = trget("ui") class DetailsDialog(DetailsDialogBase): def __init__(self, parent, app): self.vController = None + self.app = app super().__init__(parent, app) def _setupUi(self): diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index d5b948f8..32719077 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -894,7 +894,7 @@ class ScrollAreaImageViewer(QScrollArea): self._dragConnection = None self._wheelConnection = None self._instance_name = name - self.wantScrollBars = True + self.prefs = parent.app.prefs self.bestFit = True self.controller = None self.label = ScalablePixmap(self) @@ -906,11 +906,13 @@ class ScrollAreaImageViewer(QScrollArea): self.setAlignment(Qt.AlignCenter) self._verticalScrollBar = self.verticalScrollBar() self._horizontalScrollBar = self.horizontalScrollBar() - if self.wantScrollBars: + + if self.prefs.details_dialog_viewers_show_scrollbars: self.toggleScrollBars() else: self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setWidget(self.label) self.setVisible(True) @@ -918,7 +920,7 @@ class ScrollAreaImageViewer(QScrollArea): return f'{self._instance_name}' def toggleScrollBars(self, forceOn=False): - if not self.wantScrollBars: + if not self.prefs.details_dialog_viewers_show_scrollbars: return # Ensure that it's off on the first run if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded: @@ -1135,7 +1137,7 @@ class GraphicsViewViewer(QGraphicsView): self._dragConnection = None self._wheelConnection = None self._instance_name = name - self.wantScrollBars = True + self.prefs = parent.app.prefs self.bestFit = True self.controller = None self._centerPoint = QPointF() @@ -1153,7 +1155,7 @@ class GraphicsViewViewer(QGraphicsView): self._verticalScrollBar = self.verticalScrollBar() self.ignore_signal = False - if self.wantScrollBars: + if self.prefs.details_dialog_viewers_show_scrollbars: self.toggleScrollBars() else: self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) @@ -1189,7 +1191,7 @@ class GraphicsViewViewer(QGraphicsView): self.controller.onHScrollBarChanged, Qt.UniqueConnection) def toggleScrollBars(self, forceOn=False): - if not self.wantScrollBars: + if not self.prefs.details_dialog_viewers_show_scrollbars: return # Ensure that it's off on the first run if self.horizontalScrollBarPolicy() == Qt.ScrollBarAsNeeded: diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py index 05626582..574883a7 100644 --- a/qt/pe/preferences_dialog.py +++ b/qt/pe/preferences_dialog.py @@ -6,6 +6,7 @@ from PyQt5.QtWidgets import QLabel from hscommon.trans import trget +from hscommon.plat import ISLINUX from qtlib.radio_box import RadioBox from core.scanner import ScanType from core.app import AppMode @@ -45,6 +46,26 @@ class PreferencesDialog(PreferencesDialogBase): self.widgetsVLayout.addWidget(self.cacheTypeRadio) self._setupBottomPart() + def _setupDisplayPage(self): + super()._setupDisplayPage() + self._setupAddCheckbox("details_dialog_override_theme_icons", + tr("Override theme icons in viewer toolbar")) + self.details_dialog_override_theme_icons.setToolTip( + tr("Use our own internal icons instead of those provided by the theme engine")) + # Prevent changing this on platforms where themes are unpredictable + self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True) + # Insert this right after the vertical title bar option + index = self.displayVLayout.indexOf(self.details_dialog_vertical_titlebar) + self.displayVLayout.insertWidget( + index + 1, self.details_dialog_override_theme_icons) + self._setupAddCheckbox("details_dialog_viewers_show_scrollbars", + tr("Show scrollbars in image viewers")) + self.details_dialog_viewers_show_scrollbars.setToolTip( + tr("When the image displayed doesn't fit the viewport, \ +show scrollbars to span the view around")) + self.displayVLayout.insertWidget( + index + 2, self.details_dialog_viewers_show_scrollbars) + def _load(self, prefs, setchecked, section): setchecked(self.matchScaledBox, prefs.match_scaled) self.cacheTypeRadio.selected_index = ( @@ -55,9 +76,17 @@ class PreferencesDialog(PreferencesDialogBase): scan_type = prefs.get_scan_type(AppMode.Picture) fuzzy_scan = scan_type == ScanType.FuzzyBlock self.filterHardnessSlider.setEnabled(fuzzy_scan) + setchecked(self.details_dialog_override_theme_icons, + prefs.details_dialog_override_theme_icons) + setchecked(self.details_dialog_viewers_show_scrollbars, + prefs.details_dialog_viewers_show_scrollbars) def _save(self, prefs, ischecked): prefs.match_scaled = ischecked(self.matchScaledBox) prefs.picture_cache_type = ( "shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite" ) + prefs.details_dialog_override_theme_icons =\ + ischecked(self.details_dialog_override_theme_icons) + prefs.details_dialog_viewers_show_scrollbars =\ + ischecked(self.details_dialog_viewers_show_scrollbars) diff --git a/qt/preferences.py b/qt/preferences.py index e54550df..a6fdecbe 100644 --- a/qt/preferences.py +++ b/qt/preferences.py @@ -33,22 +33,24 @@ class Preferences(PreferencesBase): self.language = trans.installed_lang self.tableFontSize = get("TableFontSize", self.tableFontSize) - self.reference_bold_font = get('ReferenceBoldFont', self.reference_bold_font) - self.details_dialog_titlebar_enabled = get('DetailsDialogTitleBarEnabled', + self.reference_bold_font = get("ReferenceBoldFont", self.reference_bold_font) + self.details_dialog_titlebar_enabled = get("DetailsDialogTitleBarEnabled", self.details_dialog_titlebar_enabled) - self.details_dialog_vertical_titlebar = get('DetailsDialogVerticalTitleBar', + self.details_dialog_vertical_titlebar = get("DetailsDialogVerticalTitleBar", self.details_dialog_vertical_titlebar) # On Windows and MacOS, use internal icons by default self.details_dialog_override_theme_icons =\ - get('DetailsDialogOverrideThemeIcons', + get("DetailsDialogOverrideThemeIcons", self.details_dialog_override_theme_icons) if ISLINUX else True self.details_table_delta_foreground_color =\ - get('DetailsTableDeltaForegroundColor', self.details_table_delta_foreground_color) + get("DetailsTableDeltaForegroundColor", self.details_table_delta_foreground_color) + self.details_dialog_viewers_show_scrollbars =\ + get("DetailsDialogViewersShowScrollbars", self.details_dialog_viewers_show_scrollbars) self.result_table_ref_foreground_color =\ - get('ResultTableRefForegroundColor', self.result_table_ref_foreground_color) + get("ResultTableRefForegroundColor", self.result_table_ref_foreground_color) self.result_table_delta_foreground_color =\ - get('ResultTableDeltaForegroundColor', self.result_table_delta_foreground_color) + get("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color) self.resultWindowIsMaximized = get( "ResultWindowIsMaximized", self.resultWindowIsMaximized @@ -91,6 +93,7 @@ class Preferences(PreferencesBase): self.details_table_delta_foreground_color = QColor(250, 20, 20) # red # By default use internal icons on platforms other than Linux for now self.details_dialog_override_theme_icons = False if not ISLINUX else True + self.details_dialog_viewers_show_scrollbars = True self.result_table_ref_foreground_color = QColor(Qt.blue) self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange self.resultWindowIsMaximized = False @@ -125,13 +128,14 @@ class Preferences(PreferencesBase): set_("Language", self.language) set_("TableFontSize", self.tableFontSize) - set_('ReferenceBoldFont', self.reference_bold_font) - set_('DetailsDialogTitleBarEnabled', self.details_dialog_titlebar_enabled) - set_('DetailsDialogVerticalTitleBar', self.details_dialog_vertical_titlebar) - set_('DetailsDialogOverrideThemeIcons', self.details_dialog_override_theme_icons) - set_('DetailsTableDeltaForegroundColor', self.details_table_delta_foreground_color) - set_('ResultTableRefForegroundColor', self.result_table_ref_foreground_color) - set_('ResultTableDeltaForegroundColor', self.result_table_delta_foreground_color) + set_("ReferenceBoldFont", self.reference_bold_font) + set_("DetailsDialogTitleBarEnabled", self.details_dialog_titlebar_enabled) + set_("DetailsDialogVerticalTitleBar", self.details_dialog_vertical_titlebar) + set_("DetailsDialogOverrideThemeIcons", self.details_dialog_override_theme_icons) + set_("DetailsDialogViewersShowScrollbars", self.details_dialog_viewers_show_scrollbars) + set_("DetailsTableDeltaForegroundColor", self.details_table_delta_foreground_color) + set_("ResultTableRefForegroundColor", self.result_table_ref_foreground_color) + set_("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color) set_("ResultWindowIsMaximized", self.resultWindowIsMaximized) self.set_rect("ResultWindowRect", self.resultWindowRect) self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index b52a407f..df15bf5d 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -127,7 +127,6 @@ class PreferencesDialogBase(QDialog): def _setupBottomPart(self): # The bottom part of the pref panel is always the same in all editions. - self._setupDisplayPage() self.copyMoveLabel = QLabel(self) self.copyMoveLabel.setText(tr("Copy and Move:")) self.widgetsVLayout.addWidget(self.copyMoveLabel) @@ -185,24 +184,21 @@ class PreferencesDialogBase(QDialog): self.details_dialog_label = QLabel(tr("Details window:")) self.displayVLayout.addWidget(self.details_dialog_label) self._setupAddCheckbox("details_dialog_titlebar_enabled", - tr("Show a title bar and is dockable")) + tr("Show the title bar and can be docked")) self.details_dialog_titlebar_enabled.setToolTip( - tr("Title bar can only be disabled while the window is docked")) + tr("While the title bar is hidden, \ +use the modifier key to drag the floating window around") if ISLINUX else + tr("The title bar can only be disabled while the window is docked")) self.displayVLayout.addWidget(self.details_dialog_titlebar_enabled) self._setupAddCheckbox("details_dialog_vertical_titlebar", tr("Vertical title bar")) + self.details_dialog_vertical_titlebar.setToolTip( + tr("Change the title bar from horizontal on top, to vertical on the left side")) self.displayVLayout.addWidget(self.details_dialog_vertical_titlebar) self.details_dialog_vertical_titlebar.setEnabled( self.details_dialog_titlebar_enabled.isChecked()) self.details_dialog_titlebar_enabled.stateChanged.connect( self.details_dialog_vertical_titlebar.setEnabled) - self._setupAddCheckbox("details_dialog_override_theme_icons", - tr("Override theme icons")) - self.details_dialog_override_theme_icons.setToolTip( - tr("Use our own internal icons instead of those provided by the theme engine")) - # Prevent changing this on platforms where themes are unpredictable - self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True) - self.displayVLayout.addWidget(self.details_dialog_override_theme_icons) gridlayout = QGridLayout() self.details_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:")) gridlayout.addWidget(self.details_table_delta_foreground_color_label, 4, 0) @@ -234,6 +230,7 @@ class PreferencesDialogBase(QDialog): self.displayVLayout = QVBoxLayout() self.page_display.setLayout(self.displayVLayout) self._setupPreferenceWidgets() + self._setupDisplayPage() # self.mainVLayout.addLayout(self.widgetsVLayout) self.buttonBox = QDialogButtonBox(self) self.buttonBox.setStandardButtons( @@ -277,8 +274,6 @@ class PreferencesDialogBase(QDialog): setchecked(self.details_dialog_vertical_titlebar, prefs.details_dialog_vertical_titlebar) self.fontSizeSpinBox.setValue(prefs.tableFontSize) - setchecked(self.details_dialog_override_theme_icons, - prefs.details_dialog_override_theme_icons) self.details_table_delta_foreground_color.setColor( prefs.details_table_delta_foreground_color) self.result_table_ref_foreground_color.setColor( @@ -304,7 +299,6 @@ class PreferencesDialogBase(QDialog): prefs.reference_bold_font = ischecked(self.reference_bold_font) prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled) prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar) - prefs.details_dialog_override_theme_icons = ischecked(self.details_dialog_override_theme_icons) prefs.details_table_delta_foreground_color = self.details_table_delta_foreground_color.color prefs.result_table_ref_foreground_color = self.result_table_ref_foreground_color.color prefs.result_table_delta_foreground_color = self.result_table_delta_foreground_color.color From 79613f9b1e1a04996b581ed11bb3d28f8dceda44 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 30 Jul 2020 03:22:13 +0200 Subject: [PATCH 44/61] Fix crash quitting while details dialog active * While the details dialog is opened, if quit is triggered, the error message "'DetailsPanel' object has no attribute '_table'" is reported * A workaround is to cleanly close the dialog before tear down --- qt/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qt/app.py b/qt/app.py index 7401fe82..97cbce8f 100644 --- a/qt/app.py +++ b/qt/app.py @@ -248,6 +248,7 @@ class DupeGuru(QObject): preferences_dialog.setParent(None) def quitTriggered(self): + self.details_dialog.close() self.directories_dialog.close() def showAboutBoxTriggered(self): From 9b8637ffc8ad8a744e4e96449b027baaffe23343 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 30 Jul 2020 15:16:31 +0200 Subject: [PATCH 45/61] Stretch last header section in Result window --- qt/result_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/result_window.py b/qt/result_window.py index 764a6273..e28d10e0 100644 --- a/qt/result_window.py +++ b/qt/result_window.py @@ -332,7 +332,7 @@ class ResultWindow(QMainWindow): h = self.resultsView.horizontalHeader() h.setHighlightSections(False) h.setSectionsMovable(True) - h.setStretchLastSection(False) + h.setStretchLastSection(True) h.setDefaultAlignment(Qt.AlignLeft) self.verticalLayout.addWidget(self.resultsView) self.setCentralWidget(self.centralwidget) From 7e4f37184124be483b3d6846aadfb24da7e758a1 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 30 Jul 2020 15:30:09 +0200 Subject: [PATCH 46/61] Avoid crash when quitting * If details dialog failed to be created for some reason, avoid crashing by dereferencing a null pointer --- qt/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qt/app.py b/qt/app.py index 97cbce8f..8d3d7404 100644 --- a/qt/app.py +++ b/qt/app.py @@ -248,7 +248,8 @@ class DupeGuru(QObject): preferences_dialog.setParent(None) def quitTriggered(self): - self.details_dialog.close() + if self.details_dialog is not None: + self.details_dialog.close() self.directories_dialog.close() def showAboutBoxTriggered(self): From 23642815f6cdc30012712c51daaff9fcbad42193 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 30 Jul 2020 15:38:37 +0200 Subject: [PATCH 47/61] Remove unused properties in details table headers --- qt/details_table.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qt/details_table.py b/qt/details_table.py index c2a47874..faa0e012 100644 --- a/qt/details_table.py +++ b/qt/details_table.py @@ -86,8 +86,6 @@ class DetailsTable(QTableView): # The model needs to be set to set header stuff hheader = self.horizontalHeader() hheader.setHighlightSections(False) - hheader.setStretchLastSection(False) - hheader.resizeSection(0, 100) hheader.setSectionResizeMode(0, QHeaderView.Stretch) hheader.setSectionResizeMode(1, QHeaderView.Stretch) vheader = self.verticalHeader() From 11254381a8ba38242dde9478624221a1132a3db2 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 30 Jul 2020 20:25:20 +0200 Subject: [PATCH 48/61] Save dock panel position on quit * Restore the details dialog dock position if it was previously docked (i.e. not floating). * Since the details_dialog instance was not deleted after closing by default, the previous instances were still saving their own geometry. We now delete them explicitely if we have to recreate a new instance to avoid the signal triggering the callback to save the geometry. * Since restoreGeometry() and saveGeometry() are only called in our QDockWidget, it should be safe to modify the methods for the Preferences class (in qtlib). --- qt/app.py | 8 +++++--- qt/details_dialog.py | 10 +++++++--- qtlib/preferences.py | 13 ++++++++++--- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/qt/app.py b/qt/app.py index 8d3d7404..bd85d8a0 100644 --- a/qt/app.py +++ b/qt/app.py @@ -285,16 +285,18 @@ class DupeGuru(QObject): """Creates resultWindow and details_dialog depending on the selected ``app_mode``. """ if self.details_dialog is not None: + # The object is not deleted entirely, avoid saving its geometry in the future + # self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs) + # or simply delete it on close which is probably cleaner: + self.details_dialog.setAttribute(Qt.WA_DeleteOnClose) self.details_dialog.close() - self.details_dialog.setParent(None) + # self.details_dialog.setParent(None) # seems unnecessary if self.resultWindow is not None: self.resultWindow.close() self.resultWindow.setParent(None) self.resultWindow = ResultWindow(self.directories_dialog, self) self.directories_dialog._updateActionsState() self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self) - self.resultWindow.addDockWidget( - Qt.BottomDockWidgetArea, self.details_dialog) def show_results_window(self): self.showResultsWindow() diff --git a/qt/details_dialog.py b/qt/details_dialog.py index 517f8511..c39f81e2 100644 --- a/qt/details_dialog.py +++ b/qt/details_dialog.py @@ -24,18 +24,22 @@ class DetailsDialog(QDockWidget): # To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog # has been shown. If it has, we know that our geometry should be saved. self._shown_once = False - self.app.prefs.restoreGeometry("DetailsWindowRect", self) + self._wasDocked, area = self.app.prefs.restoreGeometry("DetailsWindowRect", self) self.tableModel = DetailsModel(self.model, app) # tableView is defined in subclasses self.tableView.setModel(self.tableModel) self.model.view = self - self.app.willSavePrefs.connect(self.appWillSavePrefs) + # self.setAttribute(Qt.WA_DeleteOnClose) + parent.addDockWidget( + area if self._wasDocked else Qt.BottomDockWidgetArea, self) def _setupUi(self): # Virtual pass def show(self): + if not self._shown_once and self._wasDocked: + self.setFloating(False) self._shown_once = True super().show() self.update_options() @@ -63,7 +67,7 @@ class DetailsDialog(QDockWidget): # --- Events def appWillSavePrefs(self): - if self._shown_once and self.isFloating(): + if self._shown_once: self.app.prefs.saveGeometry("DetailsWindowRect", self) # --- model --> view diff --git a/qtlib/preferences.py b/qtlib/preferences.py index 76a5a73c..7e3ba864 100644 --- a/qtlib/preferences.py +++ b/qtlib/preferences.py @@ -7,6 +7,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import Qt, QSettings, QRect, QObject, pyqtSignal +from PyQt5.QtWidgets import QDockWidget from hscommon.trans import trget from hscommon.util import tryint @@ -123,16 +124,22 @@ class Preferences(QObject): # We save geometry under a 5-sized int array: first item is a flag for whether the widget # is maximized and the other 4 are (x, y, w, h). m = 1 if widget.isMaximized() else 0 + d = 1 if isinstance(widget, QDockWidget) and not widget.isFloating() else 0 + area = widget.parent.dockWidgetArea(widget) if d else 0 r = widget.geometry() rectAsList = [r.x(), r.y(), r.width(), r.height()] - self.set_value(name, [m] + rectAsList) + self.set_value(name, [m, d, area] + rectAsList) def restoreGeometry(self, name, widget): geometry = self.get_value(name) - if geometry and len(geometry) == 5: - m, x, y, w, h = geometry + if geometry and len(geometry) == 7: + m, d, area, x, y, w, h = geometry if m: widget.setWindowState(Qt.WindowMaximized) else: r = QRect(x, y, w, h) widget.setGeometry(r) + if isinstance(widget, QDockWidget): + # Inform of the previous dock state and the area used + return bool(d), area + return False, 0 From 2620d0080cf982afdfc240094c3f08218def302d Mon Sep 17 00:00:00 2001 From: glubsy Date: Fri, 31 Jul 2020 22:37:18 +0200 Subject: [PATCH 49/61] Fix layout error * Avoid attempting to add a QLayout to DetailsDialog which already has a layout by removing superfluous layout setup. --- qt/me/details_dialog.py | 16 ++++++++-------- qt/se/details_dialog.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/qt/me/details_dialog.py b/qt/me/details_dialog.py index ecb947d0..61c452e7 100644 --- a/qt/me/details_dialog.py +++ b/qt/me/details_dialog.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QWidget +from PyQt5.QtWidgets import QAbstractItemView from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -19,14 +19,14 @@ class DetailsDialog(DetailsDialogBase): self.setWindowTitle(tr("Details")) self.resize(502, 295) self.setMinimumSize(QSize(250, 250)) - self.verticalLayout = QVBoxLayout(self) - self.verticalLayout.setSpacing(0) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) + # self.verticalLayout = QVBoxLayout(self) + # self.verticalLayout.setSpacing(0) + # self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.tableView = DetailsTable(self) self.tableView.setAlternatingRowColors(True) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) - self.verticalLayout.addWidget(self.tableView) - self.centralWidget = QWidget() - self.centralWidget.setLayout(self.verticalLayout) - self.setWidget(self.centralWidget) + # self.verticalLayout.addWidget(self.tableView) + # self.centralWidget = QWidget() + # self.centralWidget.setLayout(self.verticalLayout) + self.setWidget(self.tableView) diff --git a/qt/se/details_dialog.py b/qt/se/details_dialog.py index 0f922dc4..8b910bc3 100644 --- a/qt/se/details_dialog.py +++ b/qt/se/details_dialog.py @@ -5,7 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtCore import QSize -from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView, QWidget +from PyQt5.QtWidgets import QAbstractItemView from hscommon.trans import trget from ..details_dialog import DetailsDialog as DetailsDialogBase @@ -19,14 +19,14 @@ class DetailsDialog(DetailsDialogBase): self.setWindowTitle(tr("Details")) self.resize(502, 186) self.setMinimumSize(QSize(200, 0)) - self.verticalLayout = QVBoxLayout(self) - self.verticalLayout.setSpacing(0) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) + # self.verticalLayout = QVBoxLayout() + # self.verticalLayout.setSpacing(0) + # self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.tableView = DetailsTable(self) self.tableView.setAlternatingRowColors(True) self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows) self.tableView.setShowGrid(False) - self.verticalLayout.addWidget(self.tableView) - self.centralWidget = QWidget() - self.centralWidget.setLayout(self.verticalLayout) - self.setWidget(self.centralWidget) + # self.verticalLayout.addWidget(self.tableView) + # self.centralWidget = QWidget() + # self.centralWidget.setLayout(self.verticalLayout) + self.setWidget(self.tableView) From d2cdcc989bf8bf10f4f831b56c90edb613371432 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 02:09:38 +0200 Subject: [PATCH 50/61] Fix 1 pixel sized color in color picker buttons * On Linux, even with 1 pixel size, the button is filled entirely with the color selected * On MacOS, the color pixmap stays at 1 pixel so we hard code the size to 16x16 --- qt/preferences_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index df15bf5d..67b7a9c7 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -349,7 +349,7 @@ class ColorPickerButton(QPushButton): self.setColor(color) def setColor(self, color): - size = QSize(1, 1) + size = QSize(16, 16) px = QPixmap(size) if color is None: size.width = 0 From acdeb0120611a492649fd957359a259915b4044d Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 16:42:14 +0200 Subject: [PATCH 51/61] Tweak preference layout for better readability * We use GroupBoxes to group items together and surround them in a frame * Remove separator lines to avoid cluttering * Adjust columns and their stretch factors for better alignment of buttons --- qt/pe/preferences_dialog.py | 6 ++--- qt/preferences_dialog.py | 51 ++++++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py index 574883a7..74fbb573 100644 --- a/qt/pe/preferences_dialog.py +++ b/qt/pe/preferences_dialog.py @@ -55,15 +55,15 @@ class PreferencesDialog(PreferencesDialogBase): # Prevent changing this on platforms where themes are unpredictable self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True) # Insert this right after the vertical title bar option - index = self.displayVLayout.indexOf(self.details_dialog_vertical_titlebar) - self.displayVLayout.insertWidget( + index = self.details_groupbox_layout.indexOf(self.details_dialog_vertical_titlebar) + self.details_groupbox_layout.insertWidget( index + 1, self.details_dialog_override_theme_icons) self._setupAddCheckbox("details_dialog_viewers_show_scrollbars", tr("Show scrollbars in image viewers")) self.details_dialog_viewers_show_scrollbars.setToolTip( tr("When the image displayed doesn't fit the viewport, \ show scrollbars to span the view around")) - self.displayVLayout.insertWidget( + self.details_groupbox_layout.insertWidget( index + 2, self.details_dialog_viewers_show_scrollbars) def _load(self, prefs, setchecked, section): diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 67b7a9c7..3417e22b 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -25,7 +25,7 @@ from PyQt5.QtWidgets import ( QWidget, QColorDialog, QPushButton, - QFrame, + QGroupBox, ) from PyQt5.QtGui import QPixmap, QIcon @@ -152,49 +152,53 @@ class PreferencesDialogBase(QDialog): 0, horizontalWrap([self.languageLabel, self.languageComboBox, None]) ) - line = QFrame(self) - line.setFrameShape(QFrame.HLine) - self.displayVLayout.addWidget(line) + # line = QFrame(self) + # line.setFrameShape(QFrame.HLine) + # self.displayVLayout.addWidget(line) - gridlayout = QGridLayout() - self.result_table_label = QLabel(tr("Result Table:")) - gridlayout.addWidget(self.result_table_label, 0, 0) + gridlayout = QGridLayout() # We should probably use QFormLayout instead here + result_groupbox = QGroupBox("Result Table") self.fontSizeLabel = QLabel(tr("Font size:")) self.fontSizeSpinBox = QSpinBox() self.fontSizeSpinBox.setMinimum(5) gridlayout.addWidget(self.fontSizeLabel, 1, 0) - gridlayout.addWidget(self.fontSizeSpinBox, 1, 1, 1, 1, Qt.AlignLeft) + gridlayout.addWidget(self.fontSizeSpinBox, 1, 2, 1, 1, Qt.AlignLeft) self._setupAddCheckbox("reference_bold_font", - tr("Bold font for reference")) + tr("Use bold font for references")) gridlayout.addWidget(self.reference_bold_font, 2, 0) self.result_table_ref_foreground_color_label = QLabel(tr("Reference foreground color:")) gridlayout.addWidget(self.result_table_ref_foreground_color_label, 3, 0) self.result_table_ref_foreground_color = ColorPickerButton(self) - gridlayout.addWidget(self.result_table_ref_foreground_color, 3, 1, 1, 1, Qt.AlignLeft) + gridlayout.addWidget(self.result_table_ref_foreground_color, 3, 2, 1, 1, Qt.AlignLeft) self.result_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:")) gridlayout.addWidget(self.result_table_delta_foreground_color_label, 4, 0) self.result_table_delta_foreground_color = ColorPickerButton(self) - gridlayout.addWidget(self.result_table_delta_foreground_color, 4, 1, 1, 1, Qt.AlignLeft) - self.displayVLayout.addLayout(gridlayout) + gridlayout.addWidget(self.result_table_delta_foreground_color, 4, 2, 1, 1, Qt.AlignLeft) + gridlayout.setColumnStretch(1, 0) + gridlayout.setColumnStretch(3, 3) + # Keep same vertical spacing as parent layout for consistency + gridlayout.setVerticalSpacing(self.displayVLayout.spacing()) + result_groupbox.setLayout(gridlayout) + self.displayVLayout.addWidget(result_groupbox) - line = QFrame(self) - line.setFrameShape(QFrame.HLine) - self.displayVLayout.addWidget(line) + # line = QFrame(self) + # line.setFrameShape(QFrame.HLine) + # self.displayVLayout.addWidget(line) - self.details_dialog_label = QLabel(tr("Details window:")) - self.displayVLayout.addWidget(self.details_dialog_label) + details_groupbox = QGroupBox("Details window") + self.details_groupbox_layout = QVBoxLayout() self._setupAddCheckbox("details_dialog_titlebar_enabled", tr("Show the title bar and can be docked")) self.details_dialog_titlebar_enabled.setToolTip( tr("While the title bar is hidden, \ use the modifier key to drag the floating window around") if ISLINUX else tr("The title bar can only be disabled while the window is docked")) - self.displayVLayout.addWidget(self.details_dialog_titlebar_enabled) + self.details_groupbox_layout.addWidget(self.details_dialog_titlebar_enabled) self._setupAddCheckbox("details_dialog_vertical_titlebar", tr("Vertical title bar")) self.details_dialog_vertical_titlebar.setToolTip( tr("Change the title bar from horizontal on top, to vertical on the left side")) - self.displayVLayout.addWidget(self.details_dialog_vertical_titlebar) + self.details_groupbox_layout.addWidget(self.details_dialog_vertical_titlebar) self.details_dialog_vertical_titlebar.setEnabled( self.details_dialog_titlebar_enabled.isChecked()) self.details_dialog_titlebar_enabled.stateChanged.connect( @@ -203,8 +207,12 @@ use the modifier key to drag the floating window around") if ISLINUX else self.details_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:")) gridlayout.addWidget(self.details_table_delta_foreground_color_label, 4, 0) self.details_table_delta_foreground_color = ColorPickerButton(self) - gridlayout.addWidget(self.details_table_delta_foreground_color, 4, 1, 1, 1, Qt.AlignLeft) - self.displayVLayout.addLayout(gridlayout) + gridlayout.addWidget(self.details_table_delta_foreground_color, 4, 2, 1, 1, Qt.AlignLeft) + gridlayout.setColumnStretch(1, 1) + gridlayout.setColumnStretch(3, 3) + self.details_groupbox_layout.addLayout(gridlayout) + details_groupbox.setLayout(self.details_groupbox_layout) + self.displayVLayout.addWidget(details_groupbox) def _setupAddCheckbox(self, name, label, parent=None): if parent is None: @@ -228,6 +236,7 @@ use the modifier key to drag the floating window around") if ISLINUX else self.widgetsVLayout = QVBoxLayout() self.page_general.setLayout(self.widgetsVLayout) self.displayVLayout = QVBoxLayout() + self.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style self.page_display.setLayout(self.displayVLayout) self._setupPreferenceWidgets() self._setupDisplayPage() From 628d772766ea0be79a930e1065f05bf7fba15672 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 17:40:31 +0200 Subject: [PATCH 52/61] Use FormLayout instead of GridLayout QFormLayout should adhere to each platform's style better. It also simplifies the code a bit since we don't have to setup the labels, etc. --- qt/preferences_dialog.py | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 3417e22b..4f362d05 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -26,6 +26,7 @@ from PyQt5.QtWidgets import ( QColorDialog, QPushButton, QGroupBox, + QFormLayout, ) from PyQt5.QtGui import QPixmap, QIcon @@ -152,40 +153,27 @@ class PreferencesDialogBase(QDialog): 0, horizontalWrap([self.languageLabel, self.languageComboBox, None]) ) - # line = QFrame(self) - # line.setFrameShape(QFrame.HLine) - # self.displayVLayout.addWidget(line) - - gridlayout = QGridLayout() # We should probably use QFormLayout instead here - result_groupbox = QGroupBox("Result Table") - self.fontSizeLabel = QLabel(tr("Font size:")) + gridlayout = QFormLayout() + result_groupbox = QGroupBox("&Result Table") self.fontSizeSpinBox = QSpinBox() self.fontSizeSpinBox.setMinimum(5) - gridlayout.addWidget(self.fontSizeLabel, 1, 0) - gridlayout.addWidget(self.fontSizeSpinBox, 1, 2, 1, 1, Qt.AlignLeft) + gridlayout.addRow(tr("Font size:"), self.fontSizeSpinBox) self._setupAddCheckbox("reference_bold_font", tr("Use bold font for references")) - gridlayout.addWidget(self.reference_bold_font, 2, 0) - self.result_table_ref_foreground_color_label = QLabel(tr("Reference foreground color:")) - gridlayout.addWidget(self.result_table_ref_foreground_color_label, 3, 0) + gridlayout.addRow(self.reference_bold_font) + self.result_table_ref_foreground_color = ColorPickerButton(self) - gridlayout.addWidget(self.result_table_ref_foreground_color, 3, 2, 1, 1, Qt.AlignLeft) - self.result_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:")) - gridlayout.addWidget(self.result_table_delta_foreground_color_label, 4, 0) + gridlayout.addRow(tr("Reference foreground color:"), self.result_table_ref_foreground_color) self.result_table_delta_foreground_color = ColorPickerButton(self) - gridlayout.addWidget(self.result_table_delta_foreground_color, 4, 2, 1, 1, Qt.AlignLeft) - gridlayout.setColumnStretch(1, 0) - gridlayout.setColumnStretch(3, 3) + gridlayout.addRow(tr("Delta foreground color:"), self.result_table_delta_foreground_color) + gridlayout.setLabelAlignment(Qt.AlignLeft) + # Keep same vertical spacing as parent layout for consistency gridlayout.setVerticalSpacing(self.displayVLayout.spacing()) result_groupbox.setLayout(gridlayout) self.displayVLayout.addWidget(result_groupbox) - # line = QFrame(self) - # line.setFrameShape(QFrame.HLine) - # self.displayVLayout.addWidget(line) - - details_groupbox = QGroupBox("Details window") + details_groupbox = QGroupBox("&Details window") self.details_groupbox_layout = QVBoxLayout() self._setupAddCheckbox("details_dialog_titlebar_enabled", tr("Show the title bar and can be docked")) @@ -209,7 +197,7 @@ use the modifier key to drag the floating window around") if ISLINUX else self.details_table_delta_foreground_color = ColorPickerButton(self) gridlayout.addWidget(self.details_table_delta_foreground_color, 4, 2, 1, 1, Qt.AlignLeft) gridlayout.setColumnStretch(1, 1) - gridlayout.setColumnStretch(3, 3) + gridlayout.setColumnStretch(3, 4) self.details_groupbox_layout.addLayout(gridlayout) details_groupbox.setLayout(self.details_groupbox_layout) self.displayVLayout.addWidget(details_groupbox) From 056fa819cc9366c11a35c860354a1d3773f30a5d Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 18:42:46 +0200 Subject: [PATCH 53/61] Revert stretching last section in Result window * It seems that stretching the last section automatically is a bit inconvenient on MacOS as it will grow beyond the window border. * Keep it as it was before for now until a better solution is devised. --- qt/result_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/result_window.py b/qt/result_window.py index 9a3ab41b..06c24e44 100644 --- a/qt/result_window.py +++ b/qt/result_window.py @@ -360,7 +360,7 @@ class ResultWindow(QMainWindow): h = self.resultsView.horizontalHeader() h.setHighlightSections(False) h.setSectionsMovable(True) - h.setStretchLastSection(True) + h.setStretchLastSection(False) h.setDefaultAlignment(Qt.AlignLeft) self.verticalLayout.addWidget(self.resultsView) self.setCentralWidget(self.centralwidget) From a3e402a3afe350bbf26e1db365fc86d9ed72dc1e Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 18:50:44 +0200 Subject: [PATCH 54/61] Group general interface options together * Use QGroupBox to keep items together on the display tab in the preference dialog just like for the other options. * It is probably not be necessary to keep these as class members --- qt/preferences_dialog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index 24eb7d53..c04df227 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -145,19 +145,21 @@ class PreferencesDialogBase(QDialog): self.widgetsVLayout.addWidget(self.customCommandEdit) def _setupDisplayPage(self): + self.ui_groupbox = QGroupBox("&General Interface") + layout = QVBoxLayout() self.languageLabel = QLabel(tr("Language:"), self) self.languageComboBox = QComboBox(self) for lang in self.supportedLanguages: self.languageComboBox.addItem(get_langnames()[lang]) - self.displayVLayout.insertLayout( - 0, horizontalWrap([self.languageLabel, self.languageComboBox, None]) - ) + layout.addLayout(horizontalWrap([self.languageLabel, self.languageComboBox, None])) self._setupAddCheckbox("tabs_default_pos", tr("Use default position for tab bar (requires restart)")) self.tabs_default_pos.setToolTip( tr("Place the tab bar below the main menu instead of next to it\n\ On MacOS, the tab bar will fill up the window's width instead.")) - self.displayVLayout.addWidget(self.tabs_default_pos) + layout.addWidget(self.tabs_default_pos) + self.ui_groupbox.setLayout(layout) + self.displayVLayout.addWidget(self.ui_groupbox) gridlayout = QFormLayout() result_groupbox = QGroupBox("&Result Table") @@ -181,7 +183,7 @@ On MacOS, the tab bar will fill up the window's width instead.")) result_groupbox.setLayout(gridlayout) self.displayVLayout.addWidget(result_groupbox) - details_groupbox = QGroupBox("&Details window") + details_groupbox = QGroupBox("&Details Window") self.details_groupbox_layout = QVBoxLayout() self._setupAddCheckbox("details_dialog_titlebar_enabled", tr("Show the title bar and can be docked")) From de5e61293b4ae26eb6d9260da454b1beae05ba11 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 19:02:04 +0200 Subject: [PATCH 55/61] Add stretch to bottom of General pref tab --- qt/preferences_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py index c04df227..bae22e4e 100644 --- a/qt/preferences_dialog.py +++ b/qt/preferences_dialog.py @@ -251,6 +251,7 @@ use the modifier key to drag the floating window around") if ISLINUX else self.tabwidget.addTab(self.page_general, "General") self.tabwidget.addTab(self.page_display, "Display") self.displayVLayout.addStretch(0) + self.widgetsVLayout.addStretch(0) def _load(self, prefs, setchecked, section): # Edition-specific From fbd7c4fe5fa60db116b4b01b2d6a273d023bc129 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 19:07:45 +0200 Subject: [PATCH 56/61] Tweak visuals for cache selection item --- qt/pe/preferences_dialog.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py index 74fbb573..29d29313 100644 --- a/qt/pe/preferences_dialog.py +++ b/qt/pe/preferences_dialog.py @@ -4,7 +4,7 @@ # which should be included with this package. The terms are also available at # http://www.gnu.org/licenses/gpl-3.0.html -from PyQt5.QtWidgets import QLabel +from PyQt5.QtWidgets import QFormLayout from hscommon.trans import trget from hscommon.plat import ISLINUX from qtlib.radio_box import RadioBox @@ -41,9 +41,11 @@ class PreferencesDialog(PreferencesDialogBase): self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches) self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)")) self.widgetsVLayout.addWidget(self.debugModeBox) - self.widgetsVLayout.addWidget(QLabel(tr("Picture cache mode:"))) + self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False) - self.widgetsVLayout.addWidget(self.cacheTypeRadio) + cache_form = QFormLayout() + cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio) + self.widgetsVLayout.addLayout(cache_form) self._setupBottomPart() def _setupDisplayPage(self): From 0104d8922cfff393777f6982370976effc2dce8d Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 19:11:37 +0200 Subject: [PATCH 57/61] Fix alignment for combo box's label --- qt/pe/preferences_dialog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py index 29d29313..4cecb43f 100644 --- a/qt/pe/preferences_dialog.py +++ b/qt/pe/preferences_dialog.py @@ -5,6 +5,7 @@ # http://www.gnu.org/licenses/gpl-3.0.html from PyQt5.QtWidgets import QFormLayout +from PyQt5.QtCore import Qt from hscommon.trans import trget from hscommon.plat import ISLINUX from qtlib.radio_box import RadioBox @@ -44,6 +45,7 @@ class PreferencesDialog(PreferencesDialogBase): self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False) cache_form = QFormLayout() + cache_form.setLabelAlignment(Qt.AlignLeft) cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio) self.widgetsVLayout.addLayout(cache_form) self._setupBottomPart() From 866bf996cf6aec383cf6cdfa4a2ab8ce7eb2f721 Mon Sep 17 00:00:00 2001 From: glubsy Date: Sat, 1 Aug 2020 19:35:12 +0200 Subject: [PATCH 58/61] Prevent Directories tab from closing on MacOS * The close button on custom tabs cannot be hidden on MacOS for some reason. * Prevent the directories tab from closing if the close button was clicked by mistake --- qt/tabbed_window.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/qt/tabbed_window.py b/qt/tabbed_window.py index 83f1c5c8..30f004ec 100644 --- a/qt/tabbed_window.py +++ b/qt/tabbed_window.py @@ -344,6 +344,11 @@ class TabBarWindow(TabWindow): @pyqtSlot(int) def onTabCloseRequested(self, index): current_widget = self.getWidgetAtIndex(index) + if isinstance(current_widget, DirectoriesDialog): + # On MacOS, the tab has a close button even though we explicitely + # set it to None in order to hide it. This should prevent + # the "Directories" tab from closing by mistake. + return current_widget.close() self.stackedWidget.removeWidget(current_widget) # In this case the signal will take care of the tab itself after removing the widget From 76fbfc2822110c8866d8d10ba1dbe694edbbe19e Mon Sep 17 00:00:00 2001 From: glubsy Date: Sun, 2 Aug 2020 16:12:47 +0200 Subject: [PATCH 59/61] Fix adding new Result tab if already existed * Whenever the Result Window already existed and its tab was in second position, and if the ignore list tab was in 3rd position, asking to show the Result window through the View menu would add a new tab and push the Result tab to the third position (ignore list tab would then become 2nd position). * Fix view menu Directories entry not switching to index "0" in custom tab bar. --- qt/app.py | 9 ++++----- qt/tabbed_window.py | 7 ++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/qt/app.py b/qt/app.py index 8f02c341..4ed70b54 100644 --- a/qt/app.py +++ b/qt/app.py @@ -223,17 +223,14 @@ class DupeGuru(QObject): def showResultsWindow(self): if self.resultWindow is not None: if self.use_tabs: - self.main_window.addTab( - self.resultWindow, "Results", switch=True) + self.main_window.showTab(self.resultWindow) else: self.resultWindow.show() def showDirectoriesWindow(self): if self.directories_dialog is not None: if self.use_tabs: - index = self.main_window.indexOfWidget(self.directories_dialog) - self.main_window.setTabVisible(index, True) - self.main_window.setCurrentIndex(index) + self.main_window.showTab(self.directories_dialog) else: self.directories_dialog.show() @@ -354,6 +351,8 @@ class DupeGuru(QObject): if self.use_tabs: self.resultWindow = self.main_window.createPage( "ResultWindow", parent=self.main_window, app=self) + self.main_window.addTab( + self.resultWindow, "Results", switch=False) else: # We don't use a tab widget, regular floating QMainWindow self.resultWindow = ResultWindow(self.directories_dialog, self) self.directories_dialog._updateActionsState() diff --git a/qt/tabbed_window.py b/qt/tabbed_window.py index 30f004ec..f7fc13d7 100644 --- a/qt/tabbed_window.py +++ b/qt/tabbed_window.py @@ -171,6 +171,11 @@ class TabWindow(QMainWindow): self.setCurrentIndex(index) return index + def showTab(self, page): + index = self.indexOfWidget(page) + self.setTabVisible(index, True) + self.setCurrentIndex(index) + def indexOfWidget(self, widget): return self.tabWidget.indexOf(widget) @@ -302,7 +307,7 @@ class TabBarWindow(TabWindow): @pyqtSlot(int) def setTabIndex(self, index): - if not index: + if index is None: return self.tabBar.setCurrentIndex(index) From 089f00adb8e105939499bbda74978d29320a9ca2 Mon Sep 17 00:00:00 2001 From: glubsy Date: Mon, 3 Aug 2020 16:18:15 +0200 Subject: [PATCH 60/61] Fix typo in class member reference --- qt/results_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/results_model.py b/qt/results_model.py index b896bc92..ecc0ee59 100644 --- a/qt/results_model.py +++ b/qt/results_model.py @@ -20,7 +20,7 @@ class ResultsModel(Table): view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) font = view.font() font.setPointSize(app.prefs.tableFontSize) - self.view.setFont(font) + view.setFont(font) fm = QFontMetrics(font) view.verticalHeader().setDefaultSectionSize(fm.height() + 2) From b8af2a4eb5d04b5aadc1751bfc97ac8d268f46e5 Mon Sep 17 00:00:00 2001 From: glubsy Date: Thu, 3 Sep 2020 01:44:01 +0200 Subject: [PATCH 61/61] Don't show parent window's context menu on viewers * When right clicking on image viewers while they are docked, the context menu of the Results window showed up. * This also enables capture of right click and middle click buttons to drag around images, which solves a conflict with some theme engines that enable left mouse button click to drag a window's position regardless of where the event happens, hence blocking the panning. * Probably unnecessary to check which button is released. --- qt/pe/image_viewer.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/qt/pe/image_viewer.py b/qt/pe/image_viewer.py index 32719077..ef379361 100644 --- a/qt/pe/image_viewer.py +++ b/qt/pe/image_viewer.py @@ -758,11 +758,15 @@ class QWidgetImageViewer(QWidget): return self.disconnectMouseSignals() + def contextMenuEvent(self, event): + """Block parent's (main window) context menu on right click.""" + event.accept() + def mousePressEvent(self, event): if self.bestFit or not self.isEnabled(): event.ignore() return - if event.button() == Qt.LeftButton: + if event.button() & (Qt.LeftButton | Qt.MidButton | Qt.RightButton): self._drag = True else: self._drag = False @@ -790,8 +794,8 @@ class QWidgetImageViewer(QWidget): if self.bestFit or not self.isEnabled(): event.ignore() return - if event.button() == Qt.LeftButton: - self._drag = False + # if event.button() == Qt.LeftButton: + self._drag = False self._app.restoreOverrideCursor() self.setMouseTracking(False) @@ -956,11 +960,18 @@ class ScrollAreaImageViewer(QScrollArea): self._horizontalScrollBar.valueChanged.connect( self.controller.onHScrollBarChanged, Qt.UniqueConnection) + def contextMenuEvent(self, event): + """Block parent's (main window) context menu on right click.""" + # Even though we don't have a context menu right now, and the default + # contextMenuPolicy is DefaultContextMenu, we leverage that handler to + # avoid raising the Result window's Actions menu + event.accept() + def mousePressEvent(self, event): if self.bestFit: event.ignore() return - if event.button() == Qt.LeftButton: + if event.button() & (Qt.LeftButton | Qt.MidButton | Qt.RightButton): self._drag = True else: self._drag = False @@ -985,8 +996,7 @@ class ScrollAreaImageViewer(QScrollArea): if self.bestFit: event.ignore() return - if event.button() == Qt.LeftButton: - self._drag = False + self._drag = False self._app.restoreOverrideCursor() self.setMouseTracking(False) super().mouseReleaseEvent(event) @@ -1203,11 +1213,15 @@ class GraphicsViewViewer(QGraphicsView): self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + def contextMenuEvent(self, event): + """Block parent's (main window) context menu on right click.""" + event.accept() + def mousePressEvent(self, event): if self.bestFit: event.ignore() return - if event.button() == Qt.LeftButton: + if event.button() & (Qt.LeftButton | Qt.MidButton | Qt.RightButton): self._drag = True else: self._drag = False @@ -1223,8 +1237,7 @@ class GraphicsViewViewer(QGraphicsView): if self.bestFit: event.ignore() return - if event.button() == Qt.LeftButton: - self._drag = False + self._drag = False self._app.restoreOverrideCursor() self.setMouseTracking(False) self.updateCenterPoint()