v0.9.20
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user