This commit is contained in:
Ignacio Serantes
2026-04-12 08:39:07 +02:00
parent 07afab6ca3
commit 1508e629c0
7 changed files with 249 additions and 42 deletions

View File

@@ -1,3 +1,14 @@
"""
Duplicate Management Dialog Module for Bagheera.
This module implements the DuplicateManagerDialog, a specialized interface
for comparing and resolving duplicate image pairs identified by the
DuplicateDetector. It provides side-by-side viewing with synchronized
zooming and scrolling.
Classes:
DuplicateManagerDialog: A dialog to review and manage duplicate image pairs.
"""
import os
from datetime import datetime
from PySide6.QtWidgets import (
@@ -17,6 +28,15 @@ class DuplicateManagerDialog(QDialog):
A dialog to review and manage duplicate image pairs found by the detector.
"""
def __init__(self, duplicates, duplicate_cache, main_win, review_mode=False):
"""
Initializes the DuplicateManagerDialog.
Args:
duplicates (list): List of DuplicateResult tuples.
duplicate_cache (DuplicateCache): The persistent hash/exception cache.
main_win (MainWindow): Reference to the main application window.
review_mode (bool, optional): If True, shows previously ignored duplicates.
"""
super().__init__(main_win)
self.duplicates = duplicates # List of DuplicateResult
self.cache = duplicate_cache
@@ -164,7 +184,12 @@ class DuplicateManagerDialog(QDialog):
self.right_pane = self.right_pane_widget.pane
def closeEvent(self, event):
"""Disconnects signals and performs cleanup when closing."""
"""
Handles the dialog close event.
Disconnects external signals from the file system watcher and
performs cleanup on the image panes to free resources.
"""
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
try:
self.main_win.fs_watcher.file_deleted.disconnect(
@@ -181,7 +206,12 @@ class DuplicateManagerDialog(QDialog):
super().closeEvent(event)
def resizeEvent(self, event):
"""Resizes the images to fill available space when the dialog is resized."""
"""
Handles the dialog resize event.
Triggers a recalculation of the image scaling to ensure they fit
optimally within the new available space.
"""
super().resizeEvent(event) # Call base class resizeEvent
self._apply_linked_scaling()
@@ -257,7 +287,12 @@ class DuplicateManagerDialog(QDialog):
self._is_syncing = False
def wheelEvent(self, event):
"""Handles mouse wheel events for zooming (with Ctrl)."""
"""
Handles mouse wheel events for zooming.
If the Control modifier is held, zooms the active image pane
centered on the mouse cursor position.
"""
if event.modifiers() & Qt.ControlModifier and self.active_pane:
# Calculate the focus point relative to the active pane.
focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint())
@@ -270,7 +305,12 @@ class DuplicateManagerDialog(QDialog):
super().wheelEvent(event)
def keyPressEvent(self, event):
"""Handles keyboard shortcuts for zooming and duplicate management."""
"""
Handles keyboard shortcuts for navigation and actions.
Provides quick access to deletion (U/I), ignore (O), and skip (P),
as well as standard zoom controls (Z/+/ -).
"""
key = event.key()
if key == Qt.Key_U:
self._delete_left()
@@ -306,18 +346,29 @@ class DuplicateManagerDialog(QDialog):
# --- Viewer API Implementation for ImagePane ---
def set_active_pane(self, pane):
"""Sets the currently focused pane for synchronization reference."""
"""
Sets the currently focused pane for synchronization reference.
Args:
pane (ImagePane): The pane that gained focus.
"""
self.active_pane = pane
self.update_highlight()
def update_highlight(self):
"""Visual feedback for the active pane."""
"""Applies visual feedback (border) to the active pane."""
for pw in [self.left_pane_widget, self.right_pane_widget]:
pw.setStyleSheet("border: 2px solid #3498db;" if pw.pane == self.active_pane
else "border: 2px solid transparent;")
def on_metadata_changed(self, path, metadata=None):
"""Updates labels when image metadata (like tags) is modified."""
"""
Updates labels when image metadata is modified.
Args:
path (str): The file path.
metadata (dict, optional): The updated metadata.
"""
# Find the widget displaying this path and update its info
for pw in [self.left_pane_widget, self.right_pane_widget]:
if pw.pane.controller.get_current_path() == path:
@@ -331,18 +382,30 @@ class DuplicateManagerDialog(QDialog):
self.main_win.update_metadata_for_path(path, metadata)
def on_controller_list_updated(self, index):
"""Required by ImagePane API, no-op in dialog context."""
"""Required by ImagePane API, no-op in this dialog."""
pass
def update_view_for_pane(self, pane, resize_win=False):
"""Refreshes the canvas for a specific pane."""
"""
Refreshes the canvas for a specific pane.
Args:
pane (ImagePane): The pane to update.
resize_win (bool): Ignored in this context.
"""
pixmap = pane.controller.get_display_pixmap()
if not pixmap.isNull():
pane.canvas.setPixmap(pixmap)
pane.canvas.adjustSize()
def load_and_fit_image_for_pane(self, pane, restore_config=None):
"""Loads and calculates initial zoom to fit the pane viewport."""
"""
Loads and calculates initial zoom to fit the pane viewport.
Args:
pane (ImagePane): The target pane.
restore_config (dict, optional): Unused here.
"""
success, _ = pane.controller.load_image()
if success:
viewport = pane.scroll_area.viewport()
@@ -355,21 +418,29 @@ class DuplicateManagerDialog(QDialog):
self.update_view_for_pane(pane)
def reset_inactivity_timer(self):
"""Required by ImagePane API, no-op in this dialog."""
pass
def sync_filmstrip_selection(self, index):
"""Required by ImagePane API, no-op in this dialog."""
pass
def _get_clicked_face_for_pane(self, pane, pos):
"""Required by ImagePane API, no-op in this dialog."""
return None
def rename_face(self, face):
"""Required by ImagePane API, no-op in this dialog."""
pass
def toggle_fullscreen(self):
"""Required by ImagePane API, no-op in this dialog."""
pass
def _create_comparison_pane_widget(self):
"""
Factory method to create a pane widget containing an ImagePane and info labels.
"""
widget = QWidget()
v_layout = QVBoxLayout(widget)
v_layout.setContentsMargins(0, 0, 0, 0)
@@ -450,10 +521,14 @@ class DuplicateManagerDialog(QDialog):
self.table_widget.sortItems(0, Qt.DescendingOrder)
def _on_cell_changed(self, row, col, prev_row, prev_col):
"""Slot triggered when the selected row in the pairs table changes."""
if row >= 0:
self._load_pair(row)
def _load_pair(self, row):
"""
Loads the duplicate pair corresponding to the specified table row.
"""
if row < 0 or row >= self.table_widget.rowCount():
return
@@ -579,7 +654,20 @@ class DuplicateManagerDialog(QDialog):
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
filename_text, dir_text) -> bool:
"""Updates an ImagePane and its labels with file data."""
"""
Updates an ImagePane and its labels with file data.
Args:
pane_widget (QWidget): The container widget for the pane.
path (str): File path to load.
filename_color (str): Hex color for filename label.
dir_color (str): Hex color for directory label.
filename_text (str): Name to display.
dir_text (str): Directory path to display.
Returns:
bool: True if linked scaling should be disabled (e.g., animation).
"""
pane = pane_widget.pane
info_lbl = pane_widget.info_lbl
filename_lbl = pane_widget.filename_lbl
@@ -625,7 +713,12 @@ class DuplicateManagerDialog(QDialog):
return disable_linking
def _show_pane_context_menu(self, pos):
"""Displays a context menu for the pane that requested it."""
"""
Displays a context menu for the pane that requested it.
Args:
pos (QPoint): Local coordinates of the request.
"""
pane = self.sender()
path = pane.controller.get_current_path()
if not path or not os.path.exists(path):
@@ -685,7 +778,12 @@ class DuplicateManagerDialog(QDialog):
menu.exec(pane.mapToGlobal(pos))
def _handle_permanent_delete(self, path):
"""Prompts for and executes permanent deletion of a file."""
"""
Prompts for and executes permanent deletion of a file.
Args:
path (str): File path to delete.
"""
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
@@ -697,7 +795,13 @@ class DuplicateManagerDialog(QDialog):
self._handle_action(delete_path=path, permanent=True)
def _show_properties(self, path, pane):
"""Shows the file properties dialog for a pane's image."""
"""
Shows the file properties dialog for a pane's image.
Args:
path (str): File path.
pane (ImagePane): The pane containing the image.
"""
tags = pane.controller._current_tags
rating = pane.controller._current_rating
dlg = PropertiesDialog(
@@ -705,7 +809,11 @@ class DuplicateManagerDialog(QDialog):
dlg.exec()
def _on_pane_activated(self):
"""Handles pane activation to synchronize viewing state if linked."""
"""
Handles pane activation to synchronize viewing state if linked.
Ensures that the activated pane becomes the leader for synchronization.
"""
# When a pane is activated, ensure its zoom/scroll is the reference for linking
if self.panes_linked:
active_pane = self.sender() # The pane that emitted activated signal
@@ -720,7 +828,13 @@ class DuplicateManagerDialog(QDialog):
other_pane.set_scroll_relative(x_pct, y_pct)
def _sync_scroll(self, x_pct, y_pct):
"""Synchronizes scroll position between panes if linked."""
"""
Synchronizes scroll position between panes if linked.
Args:
x_pct (float): Horizontal scroll percentage.
y_pct (float): Vertical scroll percentage.
"""
if not self.panes_linked:
return
source_pane = self.sender()
@@ -730,7 +844,13 @@ class DuplicateManagerDialog(QDialog):
self.left_pane.set_scroll_relative(x_pct, y_pct)
def _sync_zoom(self, factor, source_pane=None):
"""Synchronizes zoom factor between panes if linked."""
"""
Synchronizes zoom factor between panes if linked.
Args:
factor (float): New zoom factor.
source_pane (ImagePane, optional): The leader pane.
"""
if not self.panes_linked or self._is_syncing:
return
if source_pane is None:
@@ -788,7 +908,12 @@ class DuplicateManagerDialog(QDialog):
self._is_syncing = False
def _format_size(self, size):
"""Formats a file size in bytes to a human-readable string."""
"""
Formats a file size in bytes to a human-readable string.
Args:
size (int): Size in bytes.
"""
for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024:
return f"{size:.1f} {unit}"
@@ -808,7 +933,7 @@ class DuplicateManagerDialog(QDialog):
self._handle_action(delete_path=path_to_delete)
def _toggle_link_panes(self):
"""Toggles the link state between panes."""
"""Toggles the synchronized viewing state between panes."""
self._user_link_preference = self.btn_link_panes.isChecked()
self.panes_linked = self._user_link_preference
if self.panes_linked:
@@ -823,7 +948,12 @@ class DuplicateManagerDialog(QDialog):
self.right_pane.set_scroll_relative(x_pct, y_pct)
def _on_file_deleted_externally(self, path):
"""Handles file deletion events from the FileSystemWatcher."""
"""
Handles file deletion events from the FileSystemWatcher.
Args:
path (str): The deleted path.
"""
path = os.path.abspath(path)
# 1. Identify pairs to remove and clean up the pending DB
@@ -849,7 +979,13 @@ class DuplicateManagerDialog(QDialog):
self.table_widget.setCurrentCell(new_row, 0)
def _on_file_moved_externally(self, old_path, new_path):
"""Handles file move/rename events from the FileSystemWatcher."""
"""
Handles file move/rename events from the FileSystemWatcher.
Args:
old_path (str): Original path.
new_path (str): Target path.
"""
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
@@ -901,10 +1037,12 @@ class DuplicateManagerDialog(QDialog):
def _handle_action(self, delete_path=None, skip=False, permanent=None):
"""
Handles management actions (delete, skip, keep) for duplicate pairs.
Updates the local list and the persistent databases.
Args:
delete_path: Path to delete, if any.
skip: Whether to skip the current pair.
delete_path (str, optional): Path to delete.
skip (bool): Whether to skip without ignoring permanently.
permanent (bool, optional): If True, deletes without trash.
"""
current_row = self.table_widget.currentRow()
if current_row < 0: