721 lines
31 KiB
Python
721 lines
31 KiB
Python
import os
|
|
from PySide6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame,
|
|
QSplitter, QWidget, QMessageBox, QApplication, QMenu,
|
|
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
|
|
)
|
|
from PySide6.QtGui import QPixmap, QIcon, QImageReader, QImage, QDesktopServices
|
|
from PySide6.QtCore import Qt, QSize, QTimer, QUrl
|
|
from imageviewer import ImagePane
|
|
from imagecontroller import ImageController
|
|
from constants import UITexts, APP_CONFIG
|
|
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._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()
|
|
self.table_widget.setColumnCount(2)
|
|
self.table_widget.setHorizontalHeaderLabels(["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica
|
|
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)
|
|
|
|
# Columna 0: Porcentaje (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)
|
|
sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates
|
|
|
|
# Columna 1: Nombres de ficheros
|
|
names_item = QTableWidgetItem(f"{name1} ↔ {name2}")
|
|
|
|
self.table_widget.setItem(row, 0, sim_item)
|
|
self.table_widget.setItem(row, 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
|
|
|
|
# La imagen más reciente (mtime más alto) va a la izquierda
|
|
if mtime1 >= mtime2:
|
|
self._set_pane_data(self.left_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left)
|
|
self._set_pane_data(self.right_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right)
|
|
else:
|
|
self._set_pane_data(self.left_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right)
|
|
self._set_pane_data(self.right_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left)
|
|
|
|
# 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):
|
|
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
|
|
|
|
# Metadatos
|
|
size_bytes = os.path.getsize(path)
|
|
size_str = self._format_size(size_bytes)
|
|
|
|
# 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};")
|
|
|
|
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.panes_linked = self.btn_link_panes.isChecked()
|
|
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)
|
|
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) # Use default setting
|
|
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()
|