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/images/exchange.icns b/images/exchange.icns
new file mode 100644
index 00000000..f93828b4
Binary files /dev/null and b/images/exchange.icns differ
diff --git a/images/exchange.ico b/images/exchange.ico
new file mode 100644
index 00000000..cf5226c9
Binary files /dev/null and b/images/exchange.ico differ
diff --git a/images/exchange.png b/images/exchange.png
new file mode 100644
index 00000000..6ed902b7
Binary files /dev/null and b/images/exchange.png differ
diff --git a/images/exchange_purple.png b/images/exchange_purple.png
new file mode 100644
index 00000000..21a6296a
Binary files /dev/null and b/images/exchange_purple.png differ
diff --git a/images/exchange_purple_upscaled.png b/images/exchange_purple_upscaled.png
new file mode 100644
index 00000000..1f31231c
Binary files /dev/null and b/images/exchange_purple_upscaled.png differ
diff --git a/images/exchange_purple_waifu_s4_tta8.png b/images/exchange_purple_waifu_s4_tta8.png
new file mode 100644
index 00000000..21bbcf53
Binary files /dev/null and b/images/exchange_purple_waifu_s4_tta8.png differ
diff --git a/images/exchange_purple_waifu_s4_tta8.xcf b/images/exchange_purple_waifu_s4_tta8.xcf
new file mode 100644
index 00000000..f3c922cb
Binary files /dev/null and b/images/exchange_purple_waifu_s4_tta8.xcf differ
diff --git a/images/exchange_waifu_s4_tta8.png b/images/exchange_waifu_s4_tta8.png
new file mode 100644
index 00000000..2f7a1cd9
Binary files /dev/null and b/images/exchange_waifu_s4_tta8.png differ
diff --git a/images/old_zoom_best_fit.png b/images/old_zoom_best_fit.png
new file mode 100644
index 00000000..444d4dcf
Binary files /dev/null and b/images/old_zoom_best_fit.png differ
diff --git a/images/old_zoom_in.png b/images/old_zoom_in.png
new file mode 100644
index 00000000..fbcbe2c1
Binary files /dev/null and b/images/old_zoom_in.png differ
diff --git a/images/old_zoom_original.png b/images/old_zoom_original.png
new file mode 100644
index 00000000..0bb910d6
Binary files /dev/null and b/images/old_zoom_original.png differ
diff --git a/images/old_zoom_out.png b/images/old_zoom_out.png
new file mode 100644
index 00000000..f7e84c98
Binary files /dev/null and b/images/old_zoom_out.png differ
diff --git a/qt/app.py b/qt/app.py
index 43e9e8db..4ed70b54 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
@@ -58,11 +58,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
if self.use_tabs:
self.main_window = TabBarWindow(self) if not self.prefs.tabs_default_pos else TabWindow(self)
parent_window = self.main_window
@@ -177,6 +177,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:
@@ -212,22 +215,22 @@ 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:
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()
@@ -295,6 +298,9 @@ class DupeGuru(QObject):
preferences_dialog.setParent(None)
def quitTriggered(self):
+ if self.details_dialog is not None:
+ self.details_dialog.close()
+
if self.main_window:
self.main_window.close()
else:
@@ -333,14 +339,20 @@ 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)
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/details_dialog.py b/qt/details_dialog.py
index d117f098..c39f81e2 100644
--- a/qt/details_dialog.py
+++ b/qt/details_dialog.py
@@ -7,34 +7,63 @@
# http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QDialog
+from PyQt5.QtWidgets import QDockWidget, QWidget
from .details_table import DetailsModel
+from hscommon.plat import ISLINUX
-class DetailsDialog(QDialog):
+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.
self._shown_once = False
- self.app.prefs.restoreGeometry("DetailsWindowRect", self)
- self.tableModel = DetailsModel(self.model)
+ 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()
+
+ 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:
+ 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())
+
+ 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):
diff --git a/qt/details_table.py b/qt/details_table.py
index c42aa0a6..faa0e012 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)
@@ -85,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()
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..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
+from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase
@@ -19,11 +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.verticalLayout.addWidget(self.tableView)
+ # self.centralWidget = QWidget()
+ # self.centralWidget.setLayout(self.verticalLayout)
+ self.setWidget(self.tableView)
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/details_dialog.py b/qt/pe/details_dialog.py
index 29c60899..c83ee754 100644
--- a/qt/pe/details_dialog.py
+++ b/qt/pe/details_dialog.py
@@ -4,115 +4,147 @@
# 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
+from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import (
- QVBoxLayout,
- QAbstractItemView,
- QHBoxLayout,
- QLabel,
- QSizePolicy,
-)
-
+ 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 (
+ 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
+ self.app = app
+ 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.topFrame = EmittingFrame()
+ 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)
+ # 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
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
+ @pyqtSlot(QResizeEvent)
def resizeEvent(self, event):
- self._updateImages()
+ self.ensure_same_sizes()
+ 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)
+ # 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
new file mode 100644
index 00000000..ef379361
--- /dev/null
+++ b/qt/pe/image_viewer.py
@@ -0,0 +1,1370 @@
+# 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
+from hscommon.plat import ISLINUX
+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(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,
+ 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,
+ ),
+ (
+ "actionZoomOut",
+ QKeySequence.ZoomOut,
+ 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,
+ ),
+ (
+ "actionNormalSize",
+ tr("Ctrl+/"),
+ 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,
+ ),
+ (
+ "actionBestFit",
+ tr("Ctrl+*"),
+ 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,
+ )
+ ]
+ # 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))
+ 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')
+ 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
+ self.same_dimensions = True
+
+ 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
+ previous_same_dimensions = self.same_dimensions
+ self.same_dimensions = True
+ 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.same_dimensions = False
+ 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)
+ 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())
+
+ 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.FastTransformation)
+ else:
+ # best fit, keep ratio always
+ scaledpixmap = pixmap.scaled(
+ target_size, Qt.KeepAspectRatio, Qt.FastTransformation)
+ 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)
+
+ 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"""
+ 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)
+ self.parent.verticalToolBar.buttonImgSwap.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.current_scale = 1.0
+
+ self.selectedViewer.setImage(self.selectedPixmap)
+ self.referenceViewer.setImage(self.referencePixmap)
+
+ self.centerViews()
+
+ self.selectedViewer.scaleToNormalSize()
+ self.referenceViewer.scaleToNormalSize()
+
+ 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)
+
+ 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(0, 1)
+
+
+class QWidgetController(BaseController):
+ """Specialized version for QWidget-based viewers."""
+ 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:
+ 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
+
+ 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
+
+ @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 not self.same_dimensions:
+ return
+ 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 not self.same_dimensions:
+ return
+ 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()
+ if self.referencePixmap.isNull():
+ self.parent.verticalToolBar.buttonImgSwap.setEnabled(False)
+ 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 not self.same_dimensions:
+ return
+ 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 not self.same_dimensions:
+ return
+ 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)
+ 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
+ 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.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.updateButtonsAsPerDimensions(previous_same_dimensions)
+ 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"""
+ viewer.setImage(pixmap)
+ if pixmap.isNull():
+ # viewer._item = None
+ 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 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 | Qt.MidButton | Qt.RightButton):
+ 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.controller.same_dimensions 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.prefs = parent.app.prefs
+ 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.prefs.details_dialog_viewers_show_scrollbars:
+ 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.prefs.details_dialog_viewers_show_scrollbars:
+ 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 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 | Qt.MidButton | Qt.RightButton):
+ 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
+ self._drag = False
+ self._app.restoreOverrideCursor()
+ self.setMouseTracking(False)
+ super().mouseReleaseEvent(event)
+
+ def wheelEvent(self, event):
+ if self.bestFit or not self.controller.same_dimensions:
+ 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 a full-fledged GraphicsView but is a bit buggy."""
+ 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.prefs = parent.app.prefs
+ 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.prefs.details_dialog_viewers_show_scrollbars:
+ 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.prefs.details_dialog_viewers_show_scrollbars:
+ 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 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 | Qt.MidButton | Qt.RightButton):
+ 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
+ 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 or not self.controller.same_dimensions:
+ 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):
+ 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)
+
+ 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(True)
+ 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())
diff --git a/qt/pe/preferences_dialog.py b/qt/pe/preferences_dialog.py
index c220d317..4cecb43f 100644
--- a/qt/pe/preferences_dialog.py
+++ b/qt/pe/preferences_dialog.py
@@ -4,8 +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.QtWidgets import QLabel
+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
from core.scanner import ScanType
from core.app import AppMode
@@ -40,12 +42,35 @@ 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.setLabelAlignment(Qt.AlignLeft)
+ cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio)
+ self.widgetsVLayout.addLayout(cache_form)
self._setupBottomPart()
- def _load(self, prefs, setchecked):
+ 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.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.details_groupbox_layout.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 = (
1 if prefs.picture_cache_type == "shelve" else 0
@@ -55,9 +80,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 e99e0d9b..210bb019 100644
--- a/qt/preferences.py
+++ b/qt/preferences.py
@@ -5,8 +5,11 @@
# 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
from core.app import AppMode
from core.scanner import ScanType
from qtlib.preferences import Preferences as PreferencesBase
@@ -30,7 +33,25 @@ 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.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)
+ # 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.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)
+ self.result_table_delta_foreground_color =\
+ get("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color)
+
self.resultWindowIsMaximized = get(
"ResultWindowIsMaximized", self.resultWindowIsMaximized
)
@@ -72,6 +93,14 @@ 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.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
self.resultWindowRect = None
self.directoriesWindowRect = None
@@ -107,7 +136,14 @@ class Preferences(PreferencesBase):
set_("Language", self.language)
set_("TableFontSize", self.tableFontSize)
- set_('ReferenceBoldFont', self.reference_bold_font)
+ 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)
set_("MainWindowIsMaximized", self.mainWindowIsMaximized)
self.set_rect("ResultWindowRect", self.resultWindowRect)
diff --git a/qt/preferences_dialog.py b/qt/preferences_dialog.py
index 289ae926..bae22e4e 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
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QVBoxLayout,
QHBoxLayout,
+ QGridLayout,
QLabel,
QComboBox,
QSlider,
@@ -20,11 +21,20 @@ from PyQt5.QtWidgets import (
QMessageBox,
QSpinBox,
QLayout,
+ QTabWidget,
+ QWidget,
+ QColorDialog,
+ QPushButton,
+ QGroupBox,
+ QFormLayout,
)
+from PyQt5.QtGui import QPixmap, QIcon
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
from .preferences import Preferences
@@ -50,6 +60,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
@@ -111,25 +128,6 @@ 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("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"))
- self.widgetsVLayout.addWidget(self.tabs_default_pos)
-
- 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.copyMoveLabel = QLabel(self)
self.copyMoveLabel.setText(tr("Copy and Move:"))
self.widgetsVLayout.addWidget(self.copyMoveLabel)
@@ -146,6 +144,74 @@ class PreferencesDialogBase(QDialog):
self.customCommandEdit = QLineEdit(self)
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])
+ 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."))
+ layout.addWidget(self.tabs_default_pos)
+ self.ui_groupbox.setLayout(layout)
+ self.displayVLayout.addWidget(self.ui_groupbox)
+
+ gridlayout = QFormLayout()
+ result_groupbox = QGroupBox("&Result Table")
+ self.fontSizeSpinBox = QSpinBox()
+ self.fontSizeSpinBox.setMinimum(5)
+ gridlayout.addRow(tr("Font size:"), self.fontSizeSpinBox)
+ self._setupAddCheckbox("reference_bold_font",
+ tr("Use bold font for references"))
+ gridlayout.addRow(self.reference_bold_font)
+
+ self.result_table_ref_foreground_color = ColorPickerButton(self)
+ gridlayout.addRow(tr("Reference foreground color:"),
+ self.result_table_ref_foreground_color)
+ self.result_table_delta_foreground_color = ColorPickerButton(self)
+ 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)
+
+ 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.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.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(
+ self.details_dialog_vertical_titlebar.setEnabled)
+ 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, 2, 1, 1, Qt.AlignLeft)
+ gridlayout.setColumnStretch(1, 1)
+ gridlayout.setColumnStretch(3, 4)
+ 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:
parent = self
@@ -162,19 +228,32 @@ 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.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style
+ self.page_display.setLayout(self.displayVLayout)
self._setupPreferenceWidgets()
- self.mainVLayout.addLayout(self.widgetsVLayout)
+ self._setupDisplayPage()
+ # 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)
+ self.widgetsVLayout.addStretch(0)
- def _load(self, prefs, setchecked):
+ def _load(self, prefs, setchecked, section):
# Edition-specific
pass
@@ -182,28 +261,40 @@ 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.tabs_default_pos, prefs.tabs_default_pos)
- 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.tabs_default_pos, prefs.tabs_default_pos)
+ 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)
+ 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(
+ prefs.result_table_delta_foreground_color)
+ 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
@@ -215,6 +306,11 @@ 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.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()
prefs.custom_command = str(self.customCommandEdit.text())
prefs.tableFontSize = self.fontSizeSpinBox.value()
@@ -232,11 +328,45 @@ 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)
+
+
+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(16, 16)
+ 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..ecc0ee59 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
@@ -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)
@@ -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:
diff --git a/qt/se/details_dialog.py b/qt/se/details_dialog.py
index 812c649f..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
+from PyQt5.QtWidgets import QAbstractItemView
from hscommon.trans import trget
from ..details_dialog import DetailsDialog as DetailsDialogBase
@@ -19,11 +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.verticalLayout.addWidget(self.tableView)
+ # self.centralWidget = QWidget()
+ # self.centralWidget.setLayout(self.verticalLayout)
+ self.setWidget(self.tableView)
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)
diff --git a/qt/tabbed_window.py b/qt/tabbed_window.py
index 83f1c5c8..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)
@@ -344,6 +349,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
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(
+ """ icon
+ made by Jason Cho (used with permission).
+
+
+
+
+
+ icons made by schollidesign
+ (licensed under GPL)."""))
+ self.verticalLayout.addWidget(self.label_4)
font = QFont()
font.setWeight(75)
font.setBold(True)
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