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( + """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) 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