Files
BagheeraView/duplicatedialog.py
2026-04-06 23:20:27 +02:00

856 lines
35 KiB
Python

import os
from datetime import datetime
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QSplitter, QWidget, QMessageBox, QApplication, QMenu,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
)
from PySide6.QtGui import QIcon, QImageReader, QImage, QDesktopServices
from PySide6.QtCore import Qt, QTimer, QUrl
from imageviewer import ImagePane
from constants import APP_CONFIG, UITexts
from propertiesdialog import PropertiesDialog
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):
super().__init__(main_win)
self.duplicates = duplicates # List of DuplicateResult
self.cache = duplicate_cache
self.main_win = main_win
self.review_mode = review_mode
self.active_pane = None
self.current_dup_pair = None # Stores the current DuplicateResult object
self.panes_linked = True # Default to linked
self._user_link_preference = True # Persiste la intención del usuario
self._is_syncing = False # Guard to prevent recursion during synchronization
self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE)
self.resize(1000, 700)
self._setup_ui()
self._populate_list()
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
self.main_win.fs_watcher.file_deleted.connect(
self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.connect(
self._on_file_moved_externally)
if self.duplicates:
self.table_widget.setCurrentCell(0, 0)
def _setup_ui(self):
layout = QHBoxLayout(self)
# Left side: List of pairs
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
header_layout = QHBoxLayout()
header_layout.addWidget(QLabel(UITexts.DUPLICATE_LIST_HEADER))
self.counter_lbl = QLabel()
self.counter_lbl.setStyleSheet("color: #3498db; font-weight: bold;")
header_layout.addStretch()
header_layout.addWidget(self.counter_lbl)
left_layout.addLayout(header_layout)
self.table_widget = QTableWidget()
if self.review_mode:
columns = 3
self.table_widget.setColumnCount(columns)
self.table_widget.setHorizontalHeaderLabels(
[UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode(
2, QHeaderView.Stretch)
else:
columns = 2
self.table_widget.setColumnCount(columns)
self.table_widget.setHorizontalHeaderLabels(
["%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode(
1, QHeaderView.Stretch)
self.table_widget.verticalHeader().setVisible(False)
self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table_widget.setSelectionMode(QAbstractItemView.SingleSelection)
self.table_widget.currentCellChanged.connect(self._on_cell_changed)
self.table_widget.setSortingEnabled(True)
left_layout.addWidget(self.table_widget)
# Right side: Comparison area
self.splitter = QSplitter(Qt.Vertical)
# Top area: Side by side images
self.comparison_widget = QWidget()
comp_layout = QHBoxLayout(self.comparison_widget)
# Left Image Panel
self.left_pane_widget = self._create_comparison_pane_widget()
comp_layout.addWidget(self.left_pane_widget)
# Right Image Panel
self.right_pane_widget = self._create_comparison_pane_widget()
comp_layout.addWidget(self.right_pane_widget)
# Buttons Area
button_widget = QWidget()
btn_layout = QHBoxLayout(button_widget)
self.btn_del_left = QPushButton(
QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT)
self.btn_del_left.clicked.connect(self._delete_left)
self.btn_del_right = QPushButton(
QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT)
self.btn_del_right.clicked.connect(self._delete_right)
self.btn_link_panes = QPushButton(
QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES)
self.btn_link_panes.setCheckable(True)
self.btn_link_panes.setChecked(self.panes_linked)
self.btn_link_panes.clicked.connect(self._toggle_link_panes)
self.btn_keep_both = QPushButton(
QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH)
self.btn_keep_both.clicked.connect(self._keep_both)
self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP)
self.btn_skip.clicked.connect(self._skip)
btn_layout.addWidget(self.btn_del_left)
btn_layout.addWidget(self.btn_del_right)
btn_layout.addWidget(self.btn_link_panes)
btn_layout.addStretch()
btn_layout.addWidget(self.btn_keep_both)
btn_layout.addWidget(self.btn_skip)
if self.review_mode:
self.btn_keep_both.hide()
self.btn_skip.setText(UITexts.DUPLICATE_REMOVE_IGNORED)
self.btn_skip.setIcon(QIcon.fromTheme("list-remove"))
self.similarity_lbl = QLabel()
self.similarity_lbl.setAlignment(Qt.AlignCenter)
self.similarity_lbl.setMinimumHeight(30)
self.similarity_lbl.setStyleSheet(
"font-weight: bold; color: #f39c12; font-size: 15px; "
"background-color: #222; border: 1px solid #444; border-radius: 4px;")
main_right_layout = QVBoxLayout()
main_right_layout.addWidget(self.comparison_widget, 1)
main_right_layout.addWidget(self.similarity_lbl)
main_right_layout.addWidget(button_widget)
right_container = QWidget()
right_container.setLayout(main_right_layout)
layout.addWidget(left_panel, 1)
layout.addWidget(right_container, 4)
# Store references to the actual ImagePane instances
self.left_pane = self.left_pane_widget.pane
self.right_pane = self.right_pane_widget.pane
def closeEvent(self, event):
"""Disconnects signals and performs cleanup when closing."""
if self.main_win and hasattr(self.main_win, 'fs_watcher'):
try:
self.main_win.fs_watcher.file_deleted.disconnect(
self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.disconnect(
self._on_file_moved_externally)
except (RuntimeError, TypeError):
pass
if hasattr(self, 'left_pane') and self.left_pane:
self.left_pane.cleanup()
if hasattr(self, 'right_pane') and self.right_pane:
self.right_pane.cleanup()
super().closeEvent(event)
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
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
def wheelEvent(self, event):
"""Handles mouse wheel events for zooming (with Ctrl)."""
if event.modifiers() & Qt.ControlModifier and self.active_pane:
# Calcular el punto de enfoque relativo al pane activo
focus_pos = self.active_pane.mapFromGlobal(event.globalPosition().toPoint())
if event.angleDelta().y() > 0:
self.active_pane.zoom_manager.zoom(1.1, focus_point=focus_pos)
else:
self.active_pane.zoom_manager.zoom(0.9, focus_point=focus_pos)
event.accept()
else:
super().wheelEvent(event)
def keyPressEvent(self, event):
"""Handles keyboard shortcuts for zooming and duplicate management."""
key = event.key()
if key == Qt.Key_U:
self._delete_left()
event.accept()
return
elif key == Qt.Key_I:
self._delete_right()
event.accept()
return
elif key == Qt.Key_O:
self._keep_both()
event.accept()
return
elif key == Qt.Key_P:
self._skip()
event.accept()
return
if not self.active_pane:
super().keyPressEvent(event)
return
if key == Qt.Key_Plus or key == Qt.Key_Equal:
self.active_pane.zoom_manager.zoom(1.1)
elif key == Qt.Key_Minus:
self.active_pane.zoom_manager.zoom(0.9)
elif key == Qt.Key_Z:
self.active_pane.zoom_manager.zoom(reset=True)
else:
super().keyPressEvent(event)
# --- Viewer API Implementation for ImagePane ---
def set_active_pane(self, pane):
"""Sets the currently focused pane for synchronization reference."""
self.active_pane = pane
self.update_highlight()
def update_highlight(self):
"""Visual feedback for 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."""
# 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:
size_str = self._format_size(os.path.getsize(path))
pw.info_lbl.setText(UITexts.DUPLICATE_INFO_FORMAT.format(
size=size_str,
width=pw.pane.controller.pixmap_original.width(),
height=pw.pane.controller.pixmap_original.height()))
if self.main_win:
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."""
pass
def update_view_for_pane(self, pane, resize_win=False):
"""Refreshes the canvas for a specific pane."""
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."""
success, _ = pane.controller.load_image()
if success:
viewport = pane.scroll_area.viewport()
w, h = viewport.width(), viewport.height()
# If not yet laid out, defer to next event loop
if w <= 1 or h <= 1:
QTimer.singleShot(0, lambda: self.load_and_fit_image_for_pane(pane))
return
pane.zoom_manager.calculate_initial_zoom(w, h, True)
self.update_view_for_pane(pane)
def reset_inactivity_timer(self):
pass
def sync_filmstrip_selection(self, index):
pass
def _get_clicked_face_for_pane(self, pane, pos):
return None
def rename_face(self, face):
pass
def toggle_fullscreen(self):
pass
def _create_comparison_pane_widget(self):
widget = QWidget()
v_layout = QVBoxLayout(widget)
v_layout.setContentsMargins(0, 0, 0, 0)
info_lbl = QLabel()
info_lbl.setAlignment(Qt.AlignCenter)
info_lbl.setStyleSheet("font-weight: bold; color: #aaa;")
v_layout.addWidget(info_lbl)
# Create ImagePane
pane = ImagePane(self, self.main_win.cache, [], 0, None, 0)
pane.setContextMenuPolicy(Qt.CustomContextMenu)
pane.controller.show_faces = False # Disable showing and adding areas
pane.customContextMenuRequested.connect(self._show_pane_context_menu)
v_layout.addWidget(pane)
# Attach references
widget.info_lbl = info_lbl
widget.pane = pane
widget.filename_lbl = QLabel()
widget.filename_lbl.setAlignment(Qt.AlignCenter)
widget.filename_lbl.setStyleSheet("font-size: 11px; font-weight: bold;")
v_layout.addWidget(widget.filename_lbl)
widget.dir_lbl = QLabel()
widget.dir_lbl.setAlignment(Qt.AlignCenter)
widget.dir_lbl.setStyleSheet("font-size: 9px; color: #888;")
v_layout.addWidget(widget.dir_lbl)
# Connect signals for synchronization
pane.scrolled.connect(self._sync_scroll)
pane.zoom_manager.zoomed.connect(self._sync_zoom)
pane.activated.connect(self._on_pane_activated)
return widget
def _populate_list(self):
self.table_widget.setSortingEnabled(False)
self.table_widget.blockSignals(True)
self.table_widget.setRowCount(0)
for i, dup in enumerate(self.duplicates):
name1 = os.path.basename(dup.path1)
name2 = os.path.basename(dup.path2)
row = self.table_widget.rowCount()
self.table_widget.insertRow(row)
if self.review_mode:
# Column 0: Ignored Date
ts = dup.timestamp if hasattr(dup, 'timestamp') and dup.timestamp else 0
date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") \
if ts else "-"
date_item = QTableWidgetItem(date_str)
# Store original index here for _load_pair
date_item.setData(Qt.UserRole, i)
date_item.setTextAlignment(Qt.AlignCenter)
self.table_widget.setItem(row, 0, date_item)
col_offset = 1
else:
col_offset = 0
# Columna similarity (usamos DisplayRole con int para que ordene
# numéricamente)
sim_item = QTableWidgetItem()
sim_item.setData(Qt.DisplayRole, dup.similarity
if dup.similarity is not None else 0)
sim_item.setTextAlignment(Qt.AlignCenter)
if not self.review_mode:
sim_item.setData(Qt.UserRole, i)
# Columna 1: Nombres de ficheros
names_item = QTableWidgetItem(f"{name1}{name2}")
self.table_widget.setItem(row, col_offset, sim_item)
self.table_widget.setItem(row, col_offset + 1, names_item)
self.counter_lbl.setText(str(len(self.duplicates)))
self.table_widget.blockSignals(False)
self.table_widget.setSortingEnabled(True)
self.table_widget.sortItems(0, Qt.DescendingOrder)
def _on_cell_changed(self, row, col, prev_row, prev_col):
if row >= 0:
self._load_pair(row)
def _load_pair(self, row):
if row < 0 or row >= self.table_widget.rowCount():
return
# Obtenemos el índice real de la lista duplicates guardado en el UserRole del
# item
item = self.table_widget.item(row, 0)
if not item:
return
original_index = item.data(Qt.UserRole)
dup = self.duplicates[original_index]
self.current_dup_pair = dup # Store the original pair
# Update similarity label
similarity_color = "#f39c12" # Default (amber)
if dup.similarity is not None:
if dup.similarity == 100:
similarity_color = "#2ecc71" # Green
elif dup.similarity < 80:
similarity_color = "#e74c3c" # Red
self.similarity_lbl.setText(f"{dup.similarity}% Similarity")
self.similarity_lbl.setStyleSheet(
f"font-weight: bold; color: {similarity_color}; "
"font-size: 12px; margin-top: 5px;")
self.similarity_lbl.show()
else:
self.similarity_lbl.hide()
# Get paths and their components
path_left = dup.path1
path_right = dup.path2
filename_left = os.path.basename(path_left)
dir_left = os.path.dirname(path_left)
filename_right = os.path.basename(path_right)
dir_right = os.path.dirname(path_right)
# Determine colors for comparison
green_color = "#2ecc71" # Green for match
red_color = "#e74c3c" # Red for mismatch
filename_color = green_color if filename_left == filename_right else red_color
dir_color = green_color if dir_left == dir_right else red_color
# Determine which path goes to which pane based on mtime
mtime1 = os.path.getmtime(path_left) if os.path.exists(path_left) else 0
mtime2 = os.path.getmtime(path_right) if os.path.exists(path_right) else 0
# Recent image to the left, older to the right
if mtime1 >= mtime2:
dis_l = self._set_pane_data(
self.left_pane_widget, path_left, filename_color,
dir_color, filename_left, dir_left)
dis_r = self._set_pane_data(
self.right_pane_widget, path_right, filename_color,
dir_color, filename_right, dir_right)
else:
dis_l = self._set_pane_data(
self.left_pane_widget, path_right, filename_color,
dir_color, filename_right, dir_right)
dis_r = self._set_pane_data(
self.right_pane_widget, path_left, filename_color,
dir_color, filename_left, dir_left)
can_link = not (dis_l or dis_r)
self.panes_linked = self._user_link_preference and can_link
self.btn_link_panes.setEnabled(can_link)
self.btn_link_panes.setChecked(self.panes_linked)
# Compare resolutions and highlight the best one
p_l = self.left_pane.controller.pixmap_original
p_r = self.right_pane.controller.pixmap_original
if not p_l.isNull() and not p_r.isNull():
res_l = p_l.width() * p_l.height()
res_r = p_r.width() * p_r.height()
winner = 0 # 0: none, 1: left, 2: right
if res_l > res_r:
winner = 1
elif res_r > res_l:
winner = 2
else:
# Same resolution, compare file sizes
try:
path_l = self.left_pane.controller.get_current_path()
path_r = self.right_pane.controller.get_current_path()
size_l = os.path.getsize(path_l)
size_r = os.path.getsize(path_r)
if size_l > size_r:
winner = 1
elif size_r > size_l:
winner = 2
except (OSError, AttributeError):
pass
if winner == 1:
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #2ecc71;")
self.left_pane_widget.info_lbl.setText(
"" + self.left_pane_widget.info_lbl.text())
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
elif winner == 2:
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #2ecc71;")
self.right_pane_widget.info_lbl.setText(
"" + self.right_pane_widget.info_lbl.text())
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
else:
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
else:
self.left_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
filename_text, dir_text) -> bool:
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")
pane.controller.update_list([], 0) # Clear pane
pane.load_and_fit_image()
filename_lbl.setText("N/A")
dir_lbl.setText("N/A")
return True
# Metadatos
size_bytes = os.path.getsize(path)
size_str = self._format_size(size_bytes)
# Detección de imágenes animadas o resoluciones inválidas
reader = QImageReader(path)
is_animated = reader.supportsAnimation() and reader.imageCount() > 1
is_invalid = (pane.controller.pixmap_original.isNull() or
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(
size=size_str,
width=pane.controller.pixmap_original.width(),
height=pane.controller.pixmap_original.height()))
else:
info_lbl.setText(f"{size_str} - N/A")
filename_lbl.setText(filename_text)
filename_lbl.setStyleSheet(
f"font-size: 11px; font-weight: bold; color: {filename_color};")
dir_lbl.setText(dir_text)
dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};")
return disable_linking
def _show_pane_context_menu(self, pos):
pane = self.sender()
path = pane.controller.get_current_path()
if not path or not os.path.exists(path):
return
menu = QMenu(self)
# Open with...
open_menu = menu.addMenu(
QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN)
self.main_win.populate_open_with_submenu(open_menu, path)
# Open location
action_open_default_app = menu.addAction(
QIcon.fromTheme("system-run"),
UITexts.CONTEXT_MENU_OPEN_DEFAULT_APP)
action_open_default_app.triggered.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path))))
menu.addSeparator()
# Clipboard
clip_menu = menu.addMenu(
QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD)
action_copy_image = clip_menu.addAction(
QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE)
action_copy_image.triggered.connect(
lambda: QApplication.clipboard().setImage(QImage(path)))
action_copy_path = clip_menu.addAction(
QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
action_copy_path.triggered.connect(
lambda: QApplication.clipboard().setText(path))
menu.addSeparator()
# Trash / Delete
action_trash = menu.addAction(
QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH)
action_trash.triggered.connect(
lambda: self._handle_action(delete_path=path, permanent=False))
action_delete = menu.addAction(
QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE)
action_delete.triggered.connect(
lambda: self._handle_permanent_delete(path))
menu.addSeparator()
# Properties
action_props = menu.addAction(
QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES)
action_props.triggered.connect(
lambda: self._show_properties(path, pane))
menu.exec(pane.mapToGlobal(pos))
def _handle_permanent_delete(self, path):
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() == QMessageBox.Yes:
self._handle_action(delete_path=path, permanent=True)
def _show_properties(self, path, pane):
tags = pane.controller._current_tags
rating = pane.controller._current_rating
dlg = PropertiesDialog(
path, initial_tags=tags, initial_rating=rating, parent=self)
dlg.exec()
def _on_pane_activated(self):
# 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
other_pane = self.left_pane \
if active_pane == self.right_pane else self.right_pane
self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane)
# Need to get scroll position from active_pane and apply to other
h_bar = active_pane.scroll_area.horizontalScrollBar()
v_bar = active_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
other_pane.set_scroll_relative(x_pct, y_pct)
def _sync_scroll(self, x_pct, y_pct):
if not self.panes_linked:
return
source_pane = self.sender()
if source_pane == self.left_pane:
self.right_pane.set_scroll_relative(x_pct, y_pct)
elif source_pane == self.right_pane:
self.left_pane.set_scroll_relative(x_pct, y_pct)
def _sync_zoom(self, factor, source_pane=None):
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
sender = self.sender()
source_pane = sender.parent() if sender else None
if not source_pane:
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
target_pane = self.left_pane \
if source_pane == self.right_pane else self.right_pane
target_pane.zoom_manager.zoom(absolute_factor=factor)
# 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))
finally:
self._is_syncing = False
def _format_size(self, size):
for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TiB"
def _delete_left(self):
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):
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):
self._user_link_preference = self.btn_link_panes.isChecked()
self.panes_linked = self._user_link_preference
if self.panes_linked:
# When linking, synchronize the other pane to the active one
# For simplicity, let's always sync right to left if linking is enabled
active_pane = self.left_pane
self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane)
h_bar = active_pane.scroll_area.horizontalScrollBar()
v_bar = active_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
self.right_pane.set_scroll_relative(x_pct, y_pct)
def _on_file_deleted_externally(self, path):
"""Handles file deletion events from the FileSystemWatcher."""
path = os.path.abspath(path)
# 1. Identify pairs to remove and clean up the pending DB
pairs_to_remove = [d for d in self.duplicates
if d.path1 == path or d.path2 == path]
if not pairs_to_remove:
return
for p in pairs_to_remove:
self.cache.mark_as_pending(p.path1, p.path2, False)
# 2. Update the local list
self.duplicates = [d for d in self.duplicates if d not in pairs_to_remove]
# 3. Refresh UI
self._populate_list()
if not self.duplicates:
self.close()
else:
current_row = self.table_widget.currentRow()
new_row = min(max(0, current_row), self.table_widget.rowCount() - 1)
self.table_widget.selectRow(new_row)
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."""
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
updated = False
for i, d in enumerate(self.duplicates):
if d.path1 == old_path or d.path2 == old_path:
p1 = new_path if d.path1 == old_path else d.path1
p2 = new_path if d.path2 == old_path else d.path2
# Actualizamos la tupla con nombre usando _replace
self.duplicates[i] = d._replace(path1=p1, path2=p2)
updated = True
if updated:
current_row = self.table_widget.currentRow()
self._populate_list()
if current_row >= 0:
new_row = min(current_row, self.table_widget.rowCount() - 1)
self.table_widget.selectRow(new_row)
self.table_widget.setCurrentCell(new_row, 0)
def _keep_both(self):
if self.current_dup_pair:
self.cache.mark_as_exception(
self.current_dup_pair.path1,
self.current_dup_pair.path2,
True,
similarity=self.current_dup_pair.similarity
)
self._handle_action(skip=False, permanent=False)
def _skip(self):
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)
# Borramos los hashes para que el detector las trate como imágenes nuevas
# y fuerce una nueva comparación en el siguiente escaneo.
# Usamos clear_relationships=False para no perder otras posibles
# coincidencias ya marcadas.
self.cache.remove_hash_for_path(
self.current_dup_pair.path1, clear_relationships=False)
self.cache.remove_hash_for_path(
self.current_dup_pair.path2, clear_relationships=False)
self._handle_action(skip=False, permanent=False)
else:
self._handle_action(skip=True)
def _handle_action(self, delete_path=None, skip=False, permanent=None):
current_row = self.table_widget.currentRow()
if current_row < 0:
return
item = self.table_widget.item(current_row, 0)
original_index = item.data(Qt.UserRole)
# Get the pair before potentially popping it
current_pair = self.duplicates[original_index]
if delete_path:
if permanent is not True:
if APP_CONFIG.get("duplicate_confirm_delete", True):
reply = QMessageBox.question(
self, UITexts.CONFIRM_TRASH_TITLE,
UITexts.CONFIRM_TRASH_TEXT,
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply != QMessageBox.Yes:
return
# Remove all pairs containing this path from the persistent pending DB
# because the file will be gone.
pairs_to_unmark = [d for d in self.duplicates
if d.path1 == delete_path or d.path2 == delete_path]
for p in pairs_to_unmark:
self.cache.mark_as_pending(p.path1, p.path2, False)
self.main_win.delete_file_by_path(delete_path, permanent=permanent)
if os.path.exists(delete_path):
QMessageBox.warning(
self, UITexts.ERROR,
UITexts.ERROR_DELETING_FILE.format(delete_path))
return
# Remove all pairs containing this path because it no longer exists
self.duplicates = [d for d in self.duplicates
if d.path1 != delete_path and d.path2 != delete_path]
else:
# Skip or KeepBoth:
if not skip: # "Keep Both" case
# It's no longer pending, it's an exception (already marked in
# _keep_both)
self.cache.mark_as_pending(
current_pair.path1, current_pair.path2, False)
# Note: if it's "Skip", we do NOT remove it from pending DB, so it stays
# there for next time.
if 0 <= original_index < len(self.duplicates):
self.duplicates.pop(original_index)
# Repopulate list widget to ensure all indices are correct and counter is
# updated
self._populate_list()
# Try to restore selection to same position (or last item)
if self.duplicates:
new_row = min(current_row, self.table_widget.rowCount() - 1)
self.table_widget.selectRow(new_row)
self.table_widget.setCurrentCell(new_row, 0)
else:
self.close()