This commit is contained in:
Ignacio Serantes
2026-04-08 15:47:29 +02:00
parent bff99226b0
commit 07afab6ca3
10 changed files with 336 additions and 113 deletions

View File

@@ -45,6 +45,7 @@ class DuplicateManagerDialog(QDialog):
self.table_widget.setCurrentCell(0, 0)
def _setup_ui(self):
"""Sets up the user interface components for the duplicate manager."""
layout = QHBoxLayout(self)
# Left side: List of pairs
@@ -181,15 +182,79 @@ class DuplicateManagerDialog(QDialog):
def resizeEvent(self, event):
"""Resizes the images to fill available space when the dialog is resized."""
super().resizeEvent(event)
if hasattr(self, 'left_pane') and self.left_pane and \
hasattr(self, 'right_pane') and self.right_pane:
self._is_syncing = True
super().resizeEvent(event) # Call base class resizeEvent
self._apply_linked_scaling()
def _apply_linked_scaling(self):
"""Applies custom linked scaling logic to both panels."""
if not self.left_pane or not self.right_pane:
return
# Ensure images are loaded to get original dimensions.
# This also ensures pane.controller.pixmap_original is populated.
self.left_pane.controller.load_image()
self.right_pane.controller.load_image()
p_l = self.left_pane.controller.pixmap_original
p_r = self.right_pane.controller.pixmap_original
# If panels are not linked or any image is null, adjust independently
if not self.panes_linked or p_l.isNull() or p_r.isNull():
self._is_syncing = True # Avoid recursion in _sync_zoom
try:
self.load_and_fit_image_for_pane(self.left_pane)
self.load_and_fit_image_for_pane(self.right_pane)
finally:
self._is_syncing = False
return
self._is_syncing = True
try:
# Get original dimensions
w_l_orig, h_l_orig = p_l.width(), p_l.height()
w_r_orig, h_r_orig = p_r.width(), p_r.height()
# Get available viewport size for each panel
viewport_l = self.left_pane.scroll_area.viewport()
viewport_r = self.right_pane.scroll_area.viewport()
vp_w_l, vp_h_l = viewport_l.width(), viewport_l.height()
vp_w_r, vp_h_r = viewport_r.width(), viewport_r.height()
# Determine the highest resolution image
res_l = w_l_orig * h_l_orig
res_r = w_r_orig * h_r_orig
if res_l >= res_r:
high_res_pane = self.left_pane
low_res_pane = self.right_pane
high_res_w, high_res_h = w_l_orig, h_l_orig
low_res_w, low_res_h = w_r_orig, h_r_orig
vp_w_high, vp_h_high = vp_w_l, vp_h_l
else:
high_res_pane = self.right_pane
low_res_pane = self.left_pane
high_res_w, high_res_h = w_r_orig, h_r_orig
low_res_w, low_res_h = w_l_orig, h_l_orig
vp_w_high, vp_h_high = vp_w_r, vp_h_r
# Calculate zoom factor for high-res image to fit its panel
zoom_high = 1.0
if high_res_w > 0 and high_res_h > 0:
zoom_high = min(vp_w_high / high_res_w, vp_h_high / high_res_h)
high_res_pane.controller.zoom_factor = zoom_high
high_res_pane.update_view(resize_win=False)
# Calculate and apply zoom for low-res image relative to high-res
zoom_low = 1.0
if high_res_w > 0 and high_res_h > 0:
relative_scale_factor = min(low_res_w / high_res_w,
low_res_h / high_res_h)
zoom_low = zoom_high * relative_scale_factor
low_res_pane.controller.zoom_factor = zoom_low
low_res_pane.update_view(resize_win=False)
finally:
self._is_syncing = False
def wheelEvent(self, event):
"""Handles mouse wheel events for zooming (with Ctrl)."""
@@ -340,6 +405,7 @@ class DuplicateManagerDialog(QDialog):
return widget
def _populate_list(self):
"""Fills the table widget with the list of duplicate results."""
self.table_widget.setSortingEnabled(False)
self.table_widget.blockSignals(True)
self.table_widget.setRowCount(0)
@@ -508,21 +574,29 @@ class DuplicateManagerDialog(QDialog):
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
# Force view update and proportional scaling
self._apply_linked_scaling()
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."""
pane = pane_widget.pane
info_lbl = pane_widget.info_lbl
filename_lbl = pane_widget.filename_lbl
dir_lbl = pane_widget.dir_lbl
if not os.path.exists(path):
info_lbl.setText("FILE NOT FOUND")
info_lbl.setText(UITexts.FILE_NOT_FOUND)
pane.controller.update_list([], 0) # Clear pane
pane.load_and_fit_image()
pane.controller.load_image()
filename_lbl.setText("N/A")
dir_lbl.setText("N/A")
return True
# Load image into pane's controller FIRST to get accurate pixmap state
pane.controller.update_list([path], 0)
pane.controller.load_image()
# Metadata
size_bytes = os.path.getsize(path)
size_str = self._format_size(size_bytes)
@@ -534,14 +608,6 @@ class DuplicateManagerDialog(QDialog):
not pane.controller.pixmap_original.size().isValid())
disable_linking = is_animated or is_invalid
self.panes_linked = self._user_link_preference and disable_linking
self.btn_link_panes.setEnabled(disable_linking)
self.btn_link_panes.setChecked(self.panes_linked)
# Load image into pane's controller
pane.controller.update_list([path], 0)
pane.load_and_fit_image()
# Update info labels
if not pane.controller.pixmap_original.isNull():
info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format(
@@ -559,6 +625,7 @@ class DuplicateManagerDialog(QDialog):
return disable_linking
def _show_pane_context_menu(self, pos):
"""Displays a context menu for the pane that requested it."""
pane = self.sender()
path = pane.controller.get_current_path()
if not path or not os.path.exists(path):
@@ -618,6 +685,7 @@ class DuplicateManagerDialog(QDialog):
menu.exec(pane.mapToGlobal(pos))
def _handle_permanent_delete(self, path):
"""Prompts for and executes permanent deletion of a file."""
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
@@ -629,6 +697,7 @@ 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."""
tags = pane.controller._current_tags
rating = pane.controller._current_rating
dlg = PropertiesDialog(
@@ -636,6 +705,7 @@ class DuplicateManagerDialog(QDialog):
dlg.exec()
def _on_pane_activated(self):
"""Handles pane activation to synchronize viewing state if linked."""
# 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
@@ -650,6 +720,7 @@ 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."""
if not self.panes_linked:
return
source_pane = self.sender()
@@ -659,35 +730,65 @@ 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."""
if not self.panes_linked or self._is_syncing:
return
if source_pane is None:
# El emisor es el ZoomManager, su padre es el ImagePane
# Emitter is ZoomManager, its parent is ImagePane
sender = self.sender()
source_pane = sender.parent() if sender else None
if not source_pane:
return
# Ensure both images are loaded before syncing zoom
if self.left_pane.controller.pixmap_original.isNull() or \
self.right_pane.controller.pixmap_original.isNull():
return
self._is_syncing = True
try:
# Capture current scroll percentage from source to apply to target
h_bar = source_pane.scroll_area.horizontalScrollBar()
v_bar = source_pane.scroll_area.verticalScrollBar()
x_pct = h_bar.value() / h_bar.maximum() if h_bar.maximum() > 0 else 0
y_pct = v_bar.value() / v_bar.maximum() if v_bar.maximum() > 0 else 0
p_l = self.left_pane.controller.pixmap_original
p_r = self.right_pane.controller.pixmap_original
target_pane = self.left_pane \
if source_pane == self.right_pane else self.right_pane
target_pane.zoom_manager.zoom(absolute_factor=factor)
w_l_orig, h_l_orig = p_l.width(), p_l.height()
w_r_orig, h_r_orig = p_r.width(), p_r.height()
# Re-apply relative scroll after zoom changes bounds
QTimer.singleShot(
0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
if w_l_orig == 0 or h_l_orig == 0 or w_r_orig == 0 or h_r_orig == 0:
return # Avoid division by zero
# Calculate original size relationship.
# Use ratio of "master" (high-res) to "slave" (low-res)
# to maintain relative size.
res_l = w_l_orig * h_l_orig
res_r = w_r_orig * h_r_orig
if res_l >= res_r: # Left is same or higher resolution
high_res_w, high_res_h = w_l_orig, h_l_orig
low_res_w, low_res_h = w_r_orig, h_r_orig
high_res_pane = self.left_pane
low_res_pane = self.right_pane
else: # Right is higher resolution
high_res_w, high_res_h = w_r_orig, h_r_orig
low_res_w, low_res_h = w_l_orig, h_l_orig
high_res_pane = self.right_pane
low_res_pane = self.left_pane
# 'factor' is the new zoom factor of the source panel.
# Apply this to the high-res panel, then calculate low-res zoom.
if source_pane == high_res_pane:
low_res_pane.controller.zoom_factor = factor * min(
low_res_w / high_res_w, low_res_h / high_res_h)
low_res_pane.update_view(resize_win=False)
else: # source_pane == low_res_pane
high_res_pane.controller.zoom_factor = factor / min(
low_res_w / high_res_w, low_res_h / high_res_h)
high_res_pane.update_view(resize_win=False)
finally:
self._is_syncing = False
def _format_size(self, size):
"""Formats a file size in bytes to a human-readable string."""
for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024:
return f"{size:.1f} {unit}"
@@ -695,16 +796,19 @@ class DuplicateManagerDialog(QDialog):
return f"{size:.1f} TiB"
def _delete_left(self):
"""Triggers deletion of the image in the left pane."""
path_to_delete = self.left_pane.controller.get_current_path()
if path_to_delete:
self._handle_action(delete_path=path_to_delete)
def _delete_right(self):
"""Triggers deletion of the image in the right pane."""
path_to_delete = self.right_pane.controller.get_current_path()
if path_to_delete:
self._handle_action(delete_path=path_to_delete)
def _toggle_link_panes(self):
"""Toggles the link state between panes."""
self._user_link_preference = self.btn_link_panes.isChecked()
self.panes_linked = self._user_link_preference
if self.panes_linked:
@@ -767,6 +871,7 @@ class DuplicateManagerDialog(QDialog):
self.table_widget.setCurrentCell(new_row, 0)
def _keep_both(self):
"""Marks the current pair as an exception to ignore in future scans."""
if self.current_dup_pair:
self.cache.mark_as_exception(
self.current_dup_pair.path1,
@@ -777,6 +882,7 @@ class DuplicateManagerDialog(QDialog):
self._handle_action(skip=False, permanent=False)
def _skip(self):
"""Skips the current pair without marking it as an exception."""
if self.review_mode and self.current_dup_pair:
self.cache.mark_as_exception(
self.current_dup_pair.path1, self.current_dup_pair.path2, False)
@@ -793,6 +899,13 @@ class DuplicateManagerDialog(QDialog):
self._handle_action(skip=True)
def _handle_action(self, delete_path=None, skip=False, permanent=None):
"""
Handles management actions (delete, skip, keep) for duplicate pairs.
Args:
delete_path: Path to delete, if any.
skip: Whether to skip the current pair.
"""
current_row = self.table_widget.currentRow()
if current_row < 0:
return