Fixed hang with gifs in duplicates form

This commit is contained in:
Ignacio Serantes
2026-04-06 23:20:27 +02:00
parent 45c95c1bb1
commit 964974431c
6 changed files with 298 additions and 143 deletions

View File

@@ -1037,7 +1037,7 @@ class MainWindow(QMainWindow):
self._group_info_cache = {} self._group_info_cache = {}
self._visible_paths_cache = None # Cache for visible image paths self._visible_paths_cache = None # Cache for visible image paths
self._path_to_model_index = {} self._path_to_model_index = {}
self._paths_being_modified_by_app = set() # For ignoring FS events self._paths_being_modified_by_app = set() # For ignoring FS events
# Keep references to open viewers to manage their lifecycle # Keep references to open viewers to manage their lifecycle
self.viewers = [] self.viewers = []
@@ -1844,7 +1844,8 @@ class MainWindow(QMainWindow):
self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND) self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
return return
# Por defecto usamos el modo optimizado (incremental) para no repetir comparaciones # Por defecto usamos el modo optimizado (incremental) para no repetir
# comparaciones
self.start_duplicate_detection(force_full=False, custom_paths=paths) self.start_duplicate_detection(force_full=False, custom_paths=paths)
def _gather_files_for_duplicates(self): def _gather_files_for_duplicates(self):
@@ -4879,24 +4880,28 @@ class MainWindow(QMainWindow):
new_size = new_stat.st_size new_size = new_stat.st_size
# Find old data from internal list # Find old data from internal list
old_item_data = next((item for item in self.found_items_data if item[0] == path), None) old_item_data = next((item for item in self.found_items_data
if item[0] == path), None)
old_mtime = old_item_data[2] if old_item_data else 0 old_mtime = old_item_data[2] if old_item_data else 0
old_size = os.path.getsize(path) if old_item_data else 0 # Re-read size from disk for comparison # Re-read size from disk for comparison
old_size = os.path.getsize(path) if old_item_data else 0
if new_size == old_size and new_mtime != old_mtime: if new_size == old_size and new_mtime != old_mtime:
# Likely metadata-only change (size unchanged, mtime changed) # Likely metadata-only change (size unchanged, mtime changed)
res = load_common_metadata(path) res = load_common_metadata(path)
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating, self._update_internal_data(
inode=new_stat.st_ino, dev=new_stat.st_dev) path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self.proxy_model.add_to_cache(path, res.tags) self.proxy_model.add_to_cache(path, res.tags)
self.thumbnail_view.viewport().update() # Force repaint self.thumbnail_view.viewport().update() # Force repaint
self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}") self.status_lbl.setText(f"Metadata updated: {os.path.basename(path)}")
else: else:
# Content or size changed, invalidate thumbnail and rebuild view # Content or size changed, invalidate thumbnail and rebuild view
self.cache.invalidate_path(path) self.cache.invalidate_path(path)
res = load_common_metadata(path) # Re-read metadata as well res = load_common_metadata(path) # Re-read metadata as well
self._update_internal_data(path, mtime=new_mtime, tags=res.tags, rating=res.rating, self._update_internal_data(
inode=new_stat.st_ino, dev=new_stat.st_dev) path, mtime=new_mtime, tags=res.tags, rating=res.rating,
inode=new_stat.st_ino, dev=new_stat.st_dev)
self.proxy_model.add_to_cache(path, res.tags) self.proxy_model.add_to_cache(path, res.tags)
self.rebuild_view() self.rebuild_view()
self.status_lbl.setText(f"File modified: {os.path.basename(path)}") self.status_lbl.setText(f"File modified: {os.path.basename(path)}")
@@ -4912,8 +4917,10 @@ class MainWindow(QMainWindow):
self._paths_being_modified_by_app.add(parent_path) self._paths_being_modified_by_app.add(parent_path)
# Schedule removal after a delay to allow all FS events to propagate # Schedule removal after a delay to allow all FS events to propagate
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(abs_path)) QTimer.singleShot(
QTimer.singleShot(1000, lambda: self._paths_being_modified_by_app.discard(parent_path)) 1000, lambda: self._paths_being_modified_by_app.discard(abs_path))
QTimer.singleShot(
1000, lambda: self._paths_being_modified_by_app.discard(parent_path))
def on_fs_watcher_status_changed(self, is_monitoring): def on_fs_watcher_status_changed(self, is_monitoring):
"""Updates the UI indicator for the FileSystemWatcher.""" """Updates the UI indicator for the FileSystemWatcher."""

View File

@@ -24,11 +24,14 @@ from constants import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Result structure for duplicate detection # Result structure for duplicate detection
DuplicateResult = collections.namedtuple('DuplicateResult', ['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp']) DuplicateResult = collections.namedtuple(
'DuplicateResult',
['path1', 'path2', 'hash_value', 'is_exception', 'similarity', 'timestamp'])
class BKTree: class BKTree:
"""A Burkhard-Keller tree for efficient similarity searching using Hamming distance.""" """A Burkhard-Keller tree for efficient similarity searching using Hamming
distance."""
def __init__(self, distance_func): def __init__(self, distance_func):
self.distance_func = distance_func self.distance_func = distance_func
self.tree = None self.tree = None
@@ -210,7 +213,8 @@ class DuplicateCache(QObject):
return None, 0, None return None, 0, None
with QWriteLocker(self._hash_cache_lock): with QWriteLocker(self._hash_cache_lock):
self._hash_cache[(dev_id, inode_key_bytes)] = (hash_str, mtime, path_str) self._hash_cache[(dev_id, inode_key_bytes)] = (
hash_str, mtime, path_str)
return hash_str, mtime, path_str return hash_str, mtime, path_str
return None, 0, None return None, 0, None
@@ -225,7 +229,8 @@ class DuplicateCache(QObject):
return hash_value return hash_value
return None return None
def add_hash_for_path(self, path, hash_value, mtime, dev_id=None, inode_key_bytes=None): def add_hash_for_path(self,
path, hash_value, mtime, dev_id=None, inode_key_bytes=None):
if dev_id is None or inode_key_bytes is None: if dev_id is None or inode_key_bytes is None:
dev_id, inode_key_bytes = self._get_inode_info(path) dev_id, inode_key_bytes = self._get_inode_info(path)
if not inode_key_bytes or not self._lmdb_env: if not inode_key_bytes or not self._lmdb_env:
@@ -264,8 +269,10 @@ class DuplicateCache(QObject):
# Also remove any exceptions involving this path # Also remove any exceptions involving this path
if clear_relationships: if clear_relationships:
self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._exceptions_db) self._remove_pair_entries_for_path(
self._remove_pair_entries_for_path(dev_id, inode_key_bytes, self._pending_db) dev_id, inode_key_bytes, self._exceptions_db)
self._remove_pair_entries_for_path(
dev_id, inode_key_bytes, self._pending_db)
return True return True
def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2): def _get_pair_lmdb_key_from_ids(self, dev1, inode1, dev2, inode2):
@@ -280,7 +287,9 @@ class DuplicateCache(QObject):
return None return None
return self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2) return self._get_pair_lmdb_key_from_ids(dev1, inode1, dev2, inode2)
def mark_as_exception(self, path1, path2, is_exception=True, similarity=None, timestamp=None): def mark_as_exception(self,
path1, path2, is_exception=True, similarity=None,
timestamp=None):
if not self._lmdb_env: if not self._lmdb_env:
return False return False
@@ -323,8 +332,10 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=False) as txn: with self._lmdb_env.begin(write=False) as txn:
return txn.get(exception_key, db=self._exceptions_db) is not None return txn.get(exception_key, db=self._exceptions_db) is not None
def _remove_pair_entries_for_path(self, target_dev, target_inode, db_handle, txn=None): def _remove_pair_entries_for_path(self,
"""Removes all entries involving a specific (dev, inode) pair from a pair-based DB.""" target_dev, target_inode, db_handle, txn=None):
"""Removes all entries involving a specific (dev, inode) pair from a pair-based
DB."""
if not self._lmdb_env: if not self._lmdb_env:
return return
@@ -336,8 +347,10 @@ class DuplicateCache(QObject):
for key_bytes, _ in cursor: for key_bytes, _ in cursor:
key_str = key_bytes.decode('utf-8') key_str = key_bytes.decode('utf-8')
parts = key_str.split('-') parts = key_str.split('-')
if len(parts) < 4: continue if len(parts) < 4:
dev1, inode1_hex, dev2, inode2_hex = int(parts[0]), parts[1], int(parts[2]), parts[3] continue
dev1, inode1_hex, dev2, inode2_hex = int(
parts[0]), parts[1], int(parts[2]), parts[3]
if (dev1 == target_dev and inode1_hex == target_inode_hex) or \ if (dev1 == target_dev and inode1_hex == target_inode_hex) or \
(dev2 == target_dev and inode2_hex == target_inode_hex): (dev2 == target_dev and inode2_hex == target_inode_hex):
keys_to_delete.append(key_bytes) keys_to_delete.append(key_bytes)
@@ -351,7 +364,8 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=True) as t: with self._lmdb_env.begin(write=True) as t:
do_remove(t) do_remove(t)
def mark_as_pending(self, path1, path2, is_pending=True, similarity=None, timestamp=None): def mark_as_pending(self,
path1, path2, is_pending=True, similarity=None, timestamp=None):
"""Marks a pair as pending review.""" """Marks a pair as pending review."""
if not self._lmdb_env or self._pending_db is None: if not self._lmdb_env or self._pending_db is None:
return False return False
@@ -392,7 +406,8 @@ class DuplicateCache(QObject):
sim = int(parts[2]) if len(parts) > 2 and parts[2] else None sim = int(parts[2]) if len(parts) > 2 and parts[2] else None
ts = int(parts[3]) if len(parts) > 3 else 0 ts = int(parts[3]) if len(parts) > 3 else 0
if os.path.exists(p1) and os.path.exists(p2): if os.path.exists(p1) and os.path.exists(p2):
results.append(DuplicateResult(p1, p2, None, False, sim, ts)) results.append(
DuplicateResult(p1, p2, None, False, sim, ts))
else: else:
keys_to_delete.append(key) keys_to_delete.append(key)
except Exception: except Exception:
@@ -404,7 +419,8 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=True) as txn: with self._lmdb_env.begin(write=True) as txn:
for k in keys_to_delete: for k in keys_to_delete:
txn.delete(k, db=self._pending_db) txn.delete(k, db=self._pending_db)
logger.info(f"Cleaned up {len(keys_to_delete)} invalid pending duplicates (files deleted externally)") logger.info(f"Cleaned up {len(keys_to_delete)} invalid "
"pending duplicates (files deleted externally)")
except Exception as e: except Exception as e:
logger.error(f"Error cleaning up pending duplicates from DB: {e}") logger.error(f"Error cleaning up pending duplicates from DB: {e}")
@@ -436,23 +452,28 @@ class DuplicateCache(QObject):
if len(parts) > 3: if len(parts) > 3:
ts = int(parts[3]) ts = int(parts[3])
else: else:
ts = int(os.path.getmtime(p1)) if os.path.exists(p1) else 0 ts = int(os.path.getmtime(p1)) \
if os.path.exists(p1) else 0
if not p1 or not p2: if not p1 or not p2:
# Legacy format fallback: lookup paths in hash db # Legacy format fallback: lookup paths in hash db
key_str = key_bytes.decode('utf-8') key_str = key_bytes.decode('utf-8')
kp = key_str.split('-') kp = key_str.split('-')
if len(kp) == 4: if len(kp) == 4:
k1, k2 = f"{kp[0]}-{kp[1]}".encode(), f"{kp[2]}-{kp[3]}".encode() k1, k2 = f"{kp[0]}-{kp[1]}".encode(),
v1, v2 = txn.get(k1, db=self._hash_db), txn.get(k2, db=self._hash_db) f"{kp[2]}-{kp[3]}".encode()
v1, v2 = txn.get(k1, db=self._hash_db), \
txn.get(k2, db=self._hash_db)
if v1 and v2: if v1 and v2:
# Format is hash|mtime|path|dist... path is always index 2 # Format is hash|mtime|path|dist... path is always
# index 2
p1 = v1.decode('utf-8').split('|')[2] p1 = v1.decode('utf-8').split('|')[2]
p2 = v2.decode('utf-8').split('|')[2] p2 = v2.decode('utf-8').split('|')[2]
if p1 and p2: if p1 and p2:
if os.path.exists(p1) and os.path.exists(p2): if os.path.exists(p1) and os.path.exists(p2):
results.append(DuplicateResult(p1, p2, None, True, sim, ts)) results.append(
DuplicateResult(p1, p2, None, True, sim, ts))
except Exception: except Exception:
continue continue
return results return results
@@ -484,11 +505,13 @@ class DuplicateCache(QObject):
with self._lmdb_env.begin(write=True) as txn: with self._lmdb_env.begin(write=True) as txn:
for k in keys_to_delete: for k in keys_to_delete:
txn.delete(k, db=self._hash_db) txn.delete(k, db=self._hash_db)
logger.info(f"Cleaned up {len(keys_to_delete)} stale hash entries (files deleted externally)") logger.info(f"Cleaned up {len(keys_to_delete)} stale hash "
"entries (files deleted externally)")
return len(keys_to_delete) return len(keys_to_delete)
def get_all_hashes_with_paths(self): def get_all_hashes_with_paths(self):
"""Retrieves all hashes from the database along with their associated paths and inode info.""" """Retrieves all hashes from the database along with their associated paths and
inode info."""
# hash_value -> [(path, dev_id, inode_key_bytes)] # hash_value -> [(path, dev_id, inode_key_bytes)]
all_hashes = collections.defaultdict(list) all_hashes = collections.defaultdict(list)
if not self._lmdb_env: if not self._lmdb_env:
@@ -527,7 +550,8 @@ class DuplicateCache(QObject):
if not old_inode_key_bytes or not new_inode_key_bytes or not self._lmdb_env: if not old_inode_key_bytes or not new_inode_key_bytes or not self._lmdb_env:
return False return False
# If the (dev, inode) pair is the same, only the path in the value needs updating. # If the (dev, inode) pair is the same, only the path in the value needs
# updating.
# This happens if the file is renamed within the same filesystem. # This happens if the file is renamed within the same filesystem.
if (old_dev, old_inode_key_bytes) == (new_dev, new_inode_key_bytes): if (old_dev, old_inode_key_bytes) == (new_dev, new_inode_key_bytes):
hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes) hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes)
@@ -543,8 +567,10 @@ class DuplicateCache(QObject):
# 3. Add a new entry with the new (dev, inode) and path, using the old hash. # 3. Add a new entry with the new (dev, inode) and path, using the old hash.
hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes) hash_value, mtime, _ = self.get_hash_and_path(old_dev, old_inode_key_bytes)
if hash_value: if hash_value:
self.remove_hash_for_path(old_path) # This removes the old (dev, inode) entry # This removes the old (dev, inode) entry
self.add_hash_for_path(new_path, hash_value, mtime) # Adds new (dev, inode) entry self.remove_hash_for_path(old_path)
# Adds new (dev, inode) entry
self.add_hash_for_path(new_path, hash_value, mtime)
self._update_pair_paths(old_path, new_path, self._pending_db) self._update_pair_paths(old_path, new_path, self._pending_db)
return True return True
return False return False
@@ -573,7 +599,9 @@ class DuplicateDetector(QThread):
duplicates_found = Signal(list) # List of DuplicateResult duplicates_found = Signal(list) # List of DuplicateResult
detection_finished = Signal() detection_finished = Signal()
def __init__(self, paths_to_scan, duplicate_cache, pool_manager, method="histogram_hashing", threshold=90, force_full=False): def __init__(self,
paths_to_scan, duplicate_cache, pool_manager,
method="histogram_hashing", threshold=90, force_full=False):
super().__init__() super().__init__()
self.paths_to_scan = paths_to_scan self.paths_to_scan = paths_to_scan
self.duplicate_cache = duplicate_cache self.duplicate_cache = duplicate_cache
@@ -585,17 +613,19 @@ class DuplicateDetector(QThread):
def stop(self): def stop(self):
self._is_running = False self._is_running = False
self.wait() # Add this line self.wait() # Add this line
def run(self): def run(self):
total_files = len(self.paths_to_scan) total_files = len(self.paths_to_scan)
found_duplicates = [] found_duplicates = []
unique_duplicate_pairs = set() # To store frozenset((path1, path2)) for uniqueness # To store frozenset((path1, path2)) for uniqueness
unique_duplicate_pairs = set()
last_update_time = 0 last_update_time = 0
pool = self.pool_manager.get_pool() pool = self.pool_manager.get_pool()
# 1. Load existing pending duplicates from cache to avoid recalculation (unless force_full) # 1. Load existing pending duplicates from cache to avoid recalculation (unless
# force_full)
if not self.force_full: if not self.force_full:
pending = self.duplicate_cache.get_all_pending_duplicates() pending = self.duplicate_cache.get_all_pending_duplicates()
for p in pending: for p in pending:
@@ -606,7 +636,10 @@ class DuplicateDetector(QThread):
# Convert similarity threshold (percentage) to Hamming distance # Convert similarity threshold (percentage) to Hamming distance
distance_threshold = int(MAX_DHASH_DISTANCE * (100 - self.threshold) / 100) distance_threshold = int(MAX_DHASH_DISTANCE * (100 - self.threshold) / 100)
logger.info(f"Duplicate detection: Method={self.method}, Similarity Threshold={self.threshold}%, Hamming Distance Threshold={distance_threshold}") logger.info(
f"Duplicate detection: Method={self.method}, "
f"Similarity Threshold={self.threshold}%, Hamming "
f"Distance Threshold={distance_threshold}")
# 2. Phase 1: Hash Collection (Parallelized) # 2. Phase 1: Hash Collection (Parallelized)
path_to_hash = {} path_to_hash = {}
@@ -645,7 +678,8 @@ class DuplicateDetector(QThread):
break break
current_batch = paths_to_hash_parallel[i : i + batch_size] current_batch = paths_to_hash_parallel[i : i + batch_size]
for p_data in current_batch: for p_data in current_batch:
pool.start(HashWorker(p_data[0], self, new_hashes, results_mutex, sem)) pool.start(HashWorker(
p_data[0], self, new_hashes, results_mutex, sem))
for _ in range(len(current_batch)): for _ in range(len(current_batch)):
while not sem.tryAcquire(1, 100): while not sem.tryAcquire(1, 100):
@@ -655,7 +689,9 @@ class DuplicateDetector(QThread):
break break
processed_hashing += 1 processed_hashing += 1
if time.perf_counter() - last_update_time > 0.05: if time.perf_counter() - last_update_time > 0.05:
self.progress_update.emit(processed_hashing, total_files * 2, UITexts.DUPLICATE_MSG_HASHING.format(filename="...")) self.progress_update.emit(
processed_hashing, total_files * 2,
UITexts.DUPLICATE_MSG_HASHING.format(filename="..."))
last_update_time = time.perf_counter() last_update_time = time.perf_counter()
for p, mtime, dev, inode in paths_to_hash_parallel: for p, mtime, dev, inode in paths_to_hash_parallel:
@@ -670,7 +706,9 @@ class DuplicateDetector(QThread):
return return
# Signal phase transition to exactly 50% # Signal phase transition to exactly 50%
self.progress_update.emit(total_files, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) self.progress_update.emit(
total_files, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
# 3. Phase 2: Comparison (Optimized with BK-Tree) # 3. Phase 2: Comparison (Optimized with BK-Tree)
hash_map = collections.defaultdict(list) hash_map = collections.defaultdict(list)
@@ -684,9 +722,12 @@ class DuplicateDetector(QThread):
if self.force_full or p in dirty_paths: if self.force_full or p in dirty_paths:
dirty_hashes_objs.add(h_obj) dirty_hashes_objs.add(h_obj)
# Optimization: Only query the tree for hashes associated with new or modified files. # Optimization: Only query the tree for hashes associated with new or modified
# This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were handled in previous runs. # files.
hashes_to_query = list(dirty_hashes_objs) if not self.force_full else list(hash_map.keys()) # This finds pairs (Dirty, Clean) and (Dirty, Dirty). (Clean, Clean) were
# handled in previous runs.
hashes_to_query = list(dirty_hashes_objs) \
if not self.force_full else list(hash_map.keys())
total_queries = len(hashes_to_query) total_queries = len(hashes_to_query)
for i, h1 in enumerate(hashes_to_query): for i, h1 in enumerate(hashes_to_query):
@@ -697,8 +738,11 @@ class DuplicateDetector(QThread):
if time.perf_counter() - last_update_time > 0.1: if time.perf_counter() - last_update_time > 0.1:
# Scale Phase 2 progress to the 50%-100% range # Scale Phase 2 progress to the 50%-100% range
phase2_progress = int(((i + 1) / total_queries) * total_files) if total_queries > 0 else total_files phase2_progress = int(((i + 1) / total_queries) * total_files) \
self.progress_update.emit(total_files + phase2_progress, total_files * 2, UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) if total_queries > 0 else total_files
self.progress_update.emit(
total_files + phase2_progress, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
last_update_time = time.perf_counter() last_update_time = time.perf_counter()
# Query tree for similar hashes # Query tree for similar hashes
@@ -713,7 +757,8 @@ class DuplicateDetector(QThread):
continue continue
# Optimization: Skip pair if BOTH were already verified # Optimization: Skip pair if BOTH were already verified
if not self.force_full and p1 not in dirty_paths and p2 not in dirty_paths: if not self.force_full \
and p1 not in dirty_paths and p2 not in dirty_paths:
continue continue
canonical = frozenset((p1, p2)) canonical = frozenset((p1, p2))
@@ -726,7 +771,8 @@ class DuplicateDetector(QThread):
res = DuplicateResult(p1, p2, str(h1), False, sim, ts) res = DuplicateResult(p1, p2, str(h1), False, sim, ts)
found_duplicates.append(res) found_duplicates.append(res)
unique_duplicate_pairs.add(canonical) unique_duplicate_pairs.add(canonical)
self.duplicate_cache.mark_as_pending(p1, p2, True, similarity=sim, timestamp=ts) self.duplicate_cache.mark_as_pending(
p1, p2, True, similarity=sim, timestamp=ts)
self.duplicates_found.emit(found_duplicates) self.duplicates_found.emit(found_duplicates)
self.detection_finished.emit() self.detection_finished.emit()

View File

@@ -5,11 +5,11 @@ from PySide6.QtWidgets import (
QSplitter, QWidget, QMessageBox, QApplication, QMenu, QSplitter, QWidget, QMessageBox, QApplication, QMenu,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView
) )
from PySide6.QtGui import QIcon, QImage, QDesktopServices from PySide6.QtGui import QIcon, QImageReader, QImage, QDesktopServices
from PySide6.QtCore import Qt, QTimer, QUrl from PySide6.QtCore import Qt, QTimer, QUrl
from imageviewer import ImagePane from imageviewer import ImagePane
from propertiesdialog import PropertiesDialog
from constants import APP_CONFIG, UITexts from constants import APP_CONFIG, UITexts
from propertiesdialog import PropertiesDialog
class DuplicateManagerDialog(QDialog): class DuplicateManagerDialog(QDialog):
@@ -26,6 +26,7 @@ class DuplicateManagerDialog(QDialog):
self.active_pane = None self.active_pane = None
self.current_dup_pair = None # Stores the current DuplicateResult object self.current_dup_pair = None # Stores the current DuplicateResult object
self.panes_linked = True # Default to linked 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._is_syncing = False # Guard to prevent recursion during synchronization
self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE) self.setWindowTitle(UITexts.DUPLICATE_MANAGER_TITLE)
@@ -37,7 +38,8 @@ class DuplicateManagerDialog(QDialog):
if self.main_win and hasattr(self.main_win, 'fs_watcher'): if self.main_win and hasattr(self.main_win, 'fs_watcher'):
self.main_win.fs_watcher.file_deleted.connect( self.main_win.fs_watcher.file_deleted.connect(
self._on_file_deleted_externally) self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.connect(self._on_file_moved_externally) self.main_win.fs_watcher.file_moved.connect(
self._on_file_moved_externally)
if self.duplicates: if self.duplicates:
self.table_widget.setCurrentCell(0, 0) self.table_widget.setCurrentCell(0, 0)
@@ -59,7 +61,8 @@ class DuplicateManagerDialog(QDialog):
self.table_widget = QTableWidget() self.table_widget = QTableWidget()
if self.review_mode: if self.review_mode:
self.table_widget.setColumnCount(3) columns = 3
self.table_widget.setColumnCount(columns)
self.table_widget.setHorizontalHeaderLabels( self.table_widget.setHorizontalHeaderLabels(
[UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN]) [UITexts.IGNORED_DATE, "%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode( self.table_widget.horizontalHeader().setSectionResizeMode(
@@ -69,9 +72,10 @@ class DuplicateManagerDialog(QDialog):
self.table_widget.horizontalHeader().setSectionResizeMode( self.table_widget.horizontalHeader().setSectionResizeMode(
2, QHeaderView.Stretch) 2, QHeaderView.Stretch)
else: else:
self.table_widget.setColumnCount(2) columns = 2
self.table_widget.setColumnCount(columns)
self.table_widget.setHorizontalHeaderLabels( self.table_widget.setHorizontalHeaderLabels(
["%", UITexts.CONTEXT_MENU_OPEN]) # Usamos una cadena existente o genérica ["%", UITexts.CONTEXT_MENU_OPEN])
self.table_widget.horizontalHeader().setSectionResizeMode( self.table_widget.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents) 0, QHeaderView.ResizeToContents)
self.table_widget.horizontalHeader().setSectionResizeMode( self.table_widget.horizontalHeader().setSectionResizeMode(
@@ -103,18 +107,22 @@ class DuplicateManagerDialog(QDialog):
button_widget = QWidget() button_widget = QWidget()
btn_layout = QHBoxLayout(button_widget) btn_layout = QHBoxLayout(button_widget)
self.btn_del_left = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_LEFT) 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_left.clicked.connect(self._delete_left)
self.btn_del_right = QPushButton(QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT) self.btn_del_right = QPushButton(
QIcon.fromTheme("user-trash"), UITexts.DUPLICATE_DELETE_RIGHT)
self.btn_del_right.clicked.connect(self._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 = QPushButton(
QIcon.fromTheme("object-link"), UITexts.VIEWER_MENU_LINK_PANES)
self.btn_link_panes.setCheckable(True) self.btn_link_panes.setCheckable(True)
self.btn_link_panes.setChecked(self.panes_linked) self.btn_link_panes.setChecked(self.panes_linked)
self.btn_link_panes.clicked.connect(self._toggle_link_panes) 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 = QPushButton(
QIcon.fromTheme("emblem-important"), UITexts.DUPLICATE_KEEP_BOTH)
self.btn_keep_both.clicked.connect(self._keep_both) self.btn_keep_both.clicked.connect(self._keep_both)
self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP) self.btn_skip = QPushButton(UITexts.DUPLICATE_SKIP)
@@ -135,7 +143,9 @@ class DuplicateManagerDialog(QDialog):
self.similarity_lbl = QLabel() self.similarity_lbl = QLabel()
self.similarity_lbl.setAlignment(Qt.AlignCenter) self.similarity_lbl.setAlignment(Qt.AlignCenter)
self.similarity_lbl.setMinimumHeight(30) 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;") 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 = QVBoxLayout()
main_right_layout.addWidget(self.comparison_widget, 1) main_right_layout.addWidget(self.comparison_widget, 1)
@@ -156,8 +166,10 @@ class DuplicateManagerDialog(QDialog):
"""Disconnects signals and performs cleanup when closing.""" """Disconnects signals and performs cleanup when closing."""
if self.main_win and hasattr(self.main_win, 'fs_watcher'): if self.main_win and hasattr(self.main_win, 'fs_watcher'):
try: try:
self.main_win.fs_watcher.file_deleted.disconnect(self._on_file_deleted_externally) self.main_win.fs_watcher.file_deleted.disconnect(
self.main_win.fs_watcher.file_moved.disconnect(self._on_file_moved_externally) self._on_file_deleted_externally)
self.main_win.fs_watcher.file_moved.disconnect(
self._on_file_moved_externally)
except (RuntimeError, TypeError): except (RuntimeError, TypeError):
pass pass
@@ -340,21 +352,25 @@ class DuplicateManagerDialog(QDialog):
if self.review_mode: if self.review_mode:
# Column 0: Ignored Date # Column 0: Ignored Date
ts = dup.timestamp if hasattr(dup, 'timestamp') and dup.timestamp else 0 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_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") \
if ts else "-"
date_item = QTableWidgetItem(date_str) date_item = QTableWidgetItem(date_str)
date_item.setData(Qt.UserRole, i) # Store original index here for _load_pair # Store original index here for _load_pair
date_item.setData(Qt.UserRole, i)
date_item.setTextAlignment(Qt.AlignCenter) date_item.setTextAlignment(Qt.AlignCenter)
self.table_widget.setItem(row, 0, date_item) self.table_widget.setItem(row, 0, date_item)
col_offset = 1 col_offset = 1
else: else:
col_offset = 0 col_offset = 0
# Columna similarity (usamos DisplayRole con int para que ordene numéricamente) # Columna similarity (usamos DisplayRole con int para que ordene
# numéricamente)
sim_item = QTableWidgetItem() sim_item = QTableWidgetItem()
sim_item.setData(Qt.DisplayRole, dup.similarity if dup.similarity is not None else 0) sim_item.setData(Qt.DisplayRole, dup.similarity
if dup.similarity is not None else 0)
sim_item.setTextAlignment(Qt.AlignCenter) sim_item.setTextAlignment(Qt.AlignCenter)
if not self.review_mode: if not self.review_mode:
sim_item.setData(Qt.UserRole, i) # Guardamos el índice original en la lista duplicates sim_item.setData(Qt.UserRole, i)
# Columna 1: Nombres de ficheros # Columna 1: Nombres de ficheros
names_item = QTableWidgetItem(f"{name1}{name2}") names_item = QTableWidgetItem(f"{name1}{name2}")
@@ -375,7 +391,8 @@ class DuplicateManagerDialog(QDialog):
if row < 0 or row >= self.table_widget.rowCount(): if row < 0 or row >= self.table_widget.rowCount():
return return
# Obtenemos el índice real de la lista duplicates guardado en el UserRole del item # Obtenemos el índice real de la lista duplicates guardado en el UserRole del
# item
item = self.table_widget.item(row, 0) item = self.table_widget.item(row, 0)
if not item: if not item:
return return
@@ -387,12 +404,14 @@ class DuplicateManagerDialog(QDialog):
similarity_color = "#f39c12" # Default (amber) similarity_color = "#f39c12" # Default (amber)
if dup.similarity is not None: if dup.similarity is not None:
if dup.similarity == 100: if dup.similarity == 100:
similarity_color = "#2ecc71" # Green similarity_color = "#2ecc71" # Green
elif dup.similarity < 80: elif dup.similarity < 80:
similarity_color = "#e74c3c" # Red similarity_color = "#e74c3c" # Red
self.similarity_lbl.setText(f"{dup.similarity}% Similarity") 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.setStyleSheet(
f"font-weight: bold; color: {similarity_color}; "
"font-size: 12px; margin-top: 5px;")
self.similarity_lbl.show() self.similarity_lbl.show()
else: else:
self.similarity_lbl.hide() self.similarity_lbl.hide()
@@ -417,13 +436,26 @@ class DuplicateManagerDialog(QDialog):
mtime1 = os.path.getmtime(path_left) if os.path.exists(path_left) else 0 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 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 # Recent image to the left, older to the right
if mtime1 >= mtime2: if mtime1 >= mtime2:
self._set_pane_data(self.left_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left) dis_l = self._set_pane_data(
self._set_pane_data(self.right_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right) 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: else:
self._set_pane_data(self.left_pane_widget, path_right, filename_color, dir_color, filename_right, dir_right) dis_l = self._set_pane_data(
self._set_pane_data(self.right_pane_widget, path_left, filename_color, dir_color, filename_left, dir_left) 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 # Compare resolutions and highlight the best one
p_l = self.left_pane.controller.pixmap_original p_l = self.left_pane.controller.pixmap_original
@@ -432,7 +464,7 @@ class DuplicateManagerDialog(QDialog):
res_l = p_l.width() * p_l.height() res_l = p_l.width() * p_l.height()
res_r = p_r.width() * p_r.height() res_r = p_r.width() * p_r.height()
winner = 0 # 0: none, 1: left, 2: right winner = 0 # 0: none, 1: left, 2: right
if res_l > res_r: if res_l > res_r:
winner = 1 winner = 1
elif res_r > res_l: elif res_r > res_l:
@@ -444,26 +476,40 @@ class DuplicateManagerDialog(QDialog):
path_r = self.right_pane.controller.get_current_path() path_r = self.right_pane.controller.get_current_path()
size_l = os.path.getsize(path_l) size_l = os.path.getsize(path_l)
size_r = os.path.getsize(path_r) size_r = os.path.getsize(path_r)
if size_l > size_r: winner = 1 if size_l > size_r:
elif size_r > size_l: winner = 2 winner = 1
except (OSError, AttributeError): pass elif size_r > size_l:
winner = 2
except (OSError, AttributeError):
pass
if winner == 1: if winner == 1:
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;") self.left_pane_widget.info_lbl.setStyleSheet(
self.left_pane_widget.info_lbl.setText("" + self.left_pane_widget.info_lbl.text()) "font-weight: bold; color: #2ecc71;")
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") 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: elif winner == 2:
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #2ecc71;") self.right_pane_widget.info_lbl.setStyleSheet(
self.right_pane_widget.info_lbl.setText("" + self.right_pane_widget.info_lbl.text()) "font-weight: bold; color: #2ecc71;")
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") 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: else:
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") self.left_pane_widget.info_lbl.setStyleSheet(
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") "font-weight: bold; color: #aaa;")
self.right_pane_widget.info_lbl.setStyleSheet(
"font-weight: bold; color: #aaa;")
else: else:
self.left_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") self.left_pane_widget.info_lbl.setStyleSheet(
self.right_pane_widget.info_lbl.setStyleSheet("font-weight: bold; color: #aaa;") "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): def _set_pane_data(self, pane_widget, path, filename_color, dir_color,
filename_text, dir_text) -> bool:
pane = pane_widget.pane pane = pane_widget.pane
info_lbl = pane_widget.info_lbl info_lbl = pane_widget.info_lbl
filename_lbl = pane_widget.filename_lbl filename_lbl = pane_widget.filename_lbl
@@ -471,16 +517,27 @@ class DuplicateManagerDialog(QDialog):
if not os.path.exists(path): if not os.path.exists(path):
info_lbl.setText("FILE NOT FOUND") info_lbl.setText("FILE NOT FOUND")
pane.controller.update_list([], 0) # Clear pane pane.controller.update_list([], 0) # Clear pane
pane.load_and_fit_image() pane.load_and_fit_image()
filename_lbl.setText("N/A") filename_lbl.setText("N/A")
dir_lbl.setText("N/A") dir_lbl.setText("N/A")
return return True
# Metadatos # Metadatos
size_bytes = os.path.getsize(path) size_bytes = os.path.getsize(path)
size_str = self._format_size(size_bytes) 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 # Load image into pane's controller
pane.controller.update_list([path], 0) pane.controller.update_list([path], 0)
pane.load_and_fit_image() pane.load_and_fit_image()
@@ -495,9 +552,11 @@ class DuplicateManagerDialog(QDialog):
info_lbl.setText(f"{size_str} - N/A") info_lbl.setText(f"{size_str} - N/A")
filename_lbl.setText(filename_text) filename_lbl.setText(filename_text)
filename_lbl.setStyleSheet(f"font-size: 11px; font-weight: bold; color: {filename_color};") filename_lbl.setStyleSheet(
f"font-size: 11px; font-weight: bold; color: {filename_color};")
dir_lbl.setText(dir_text) dir_lbl.setText(dir_text)
dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};") dir_lbl.setStyleSheet(f"font-size: 9px; color: {dir_color};")
return disable_linking
def _show_pane_context_menu(self, pos): def _show_pane_context_menu(self, pos):
pane = self.sender() pane = self.sender()
@@ -508,7 +567,8 @@ class DuplicateManagerDialog(QDialog):
menu = QMenu(self) menu = QMenu(self)
# Open with... # Open with...
open_menu = menu.addMenu(QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN) open_menu = menu.addMenu(
QIcon.fromTheme("document-open"), UITexts.CONTEXT_MENU_OPEN)
self.main_win.populate_open_with_submenu(open_menu, path) self.main_win.populate_open_with_submenu(open_menu, path)
# Open location # Open location
@@ -521,28 +581,39 @@ class DuplicateManagerDialog(QDialog):
menu.addSeparator() menu.addSeparator()
# Clipboard # Clipboard
clip_menu = menu.addMenu(QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_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 = clip_menu.addAction(
action_copy_image.triggered.connect(lambda: QApplication.clipboard().setImage(QImage(path))) 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 = clip_menu.addAction(
action_copy_path.triggered.connect(lambda: QApplication.clipboard().setText(path)) QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
action_copy_path.triggered.connect(
lambda: QApplication.clipboard().setText(path))
menu.addSeparator() menu.addSeparator()
# Trash / Delete # Trash / Delete
action_trash = menu.addAction(QIcon.fromTheme("user-trash"), UITexts.CONTEXT_MENU_TRASH) action_trash = menu.addAction(
action_trash.triggered.connect(lambda: self._handle_action(delete_path=path, permanent=False)) 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 = menu.addAction(
action_delete.triggered.connect(lambda: self._handle_permanent_delete(path)) QIcon.fromTheme("edit-delete"), UITexts.CONTEXT_MENU_DELETE)
action_delete.triggered.connect(
lambda: self._handle_permanent_delete(path))
menu.addSeparator() menu.addSeparator()
# Properties # Properties
action_props = menu.addAction(QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES) action_props = menu.addAction(
action_props.triggered.connect(lambda: self._show_properties(path, pane)) QIcon.fromTheme("document-properties"), UITexts.CONTEXT_MENU_PROPERTIES)
action_props.triggered.connect(
lambda: self._show_properties(path, pane))
menu.exec(pane.mapToGlobal(pos)) menu.exec(pane.mapToGlobal(pos))
@@ -550,7 +621,8 @@ class DuplicateManagerDialog(QDialog):
confirm = QMessageBox(self) confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning) confirm.setIcon(QMessageBox.Warning)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT) confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path))) confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No) confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() == QMessageBox.Yes: if confirm.exec() == QMessageBox.Yes:
@@ -559,14 +631,16 @@ class DuplicateManagerDialog(QDialog):
def _show_properties(self, path, pane): def _show_properties(self, path, pane):
tags = pane.controller._current_tags tags = pane.controller._current_tags
rating = pane.controller._current_rating rating = pane.controller._current_rating
dlg = PropertiesDialog(path, initial_tags=tags, initial_rating=rating, parent=self) dlg = PropertiesDialog(
path, initial_tags=tags, initial_rating=rating, parent=self)
dlg.exec() dlg.exec()
def _on_pane_activated(self): def _on_pane_activated(self):
# When a pane is activated, ensure its zoom/scroll is the reference for linking # When a pane is activated, ensure its zoom/scroll is the reference for linking
if self.panes_linked: if self.panes_linked:
active_pane = self.sender() # The pane that emitted activated signal 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 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) self._sync_zoom(active_pane.controller.zoom_factor, source_pane=active_pane)
# Need to get scroll position from active_pane and apply to other # Need to get scroll position from active_pane and apply to other
h_bar = active_pane.scroll_area.horizontalScrollBar() h_bar = active_pane.scroll_area.horizontalScrollBar()
@@ -603,17 +677,20 @@ class DuplicateManagerDialog(QDialog):
x_pct = h_bar.value() / h_bar.maximum() if h_bar.maximum() > 0 else 0 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 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 = self.left_pane \
if source_pane == self.right_pane else self.right_pane
target_pane.zoom_manager.zoom(absolute_factor=factor) target_pane.zoom_manager.zoom(absolute_factor=factor)
# Re-apply relative scroll after zoom changes bounds # 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)) QTimer.singleShot(
0, lambda p=target_pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y))
finally: finally:
self._is_syncing = False self._is_syncing = False
def _format_size(self, size): def _format_size(self, size):
for unit in ['B', 'KiB', 'MiB', 'GiB']: for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024: return f"{size:.1f} {unit}" if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024 size /= 1024
return f"{size:.1f} TiB" return f"{size:.1f} TiB"
@@ -628,7 +705,8 @@ class DuplicateManagerDialog(QDialog):
self._handle_action(delete_path=path_to_delete) self._handle_action(delete_path=path_to_delete)
def _toggle_link_panes(self): def _toggle_link_panes(self):
self.panes_linked = self.btn_link_panes.isChecked() self._user_link_preference = self.btn_link_panes.isChecked()
self.panes_linked = self._user_link_preference
if self.panes_linked: if self.panes_linked:
# When linking, synchronize the other pane to the active one # When linking, synchronize the other pane to the active one
# For simplicity, let's always sync right to left if linking is enabled # For simplicity, let's always sync right to left if linking is enabled
@@ -645,7 +723,8 @@ class DuplicateManagerDialog(QDialog):
path = os.path.abspath(path) path = os.path.abspath(path)
# 1. Identify pairs to remove and clean up the pending DB # 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] pairs_to_remove = [d for d in self.duplicates
if d.path1 == path or d.path2 == path]
if not pairs_to_remove: if not pairs_to_remove:
return return
@@ -699,12 +778,16 @@ class DuplicateManagerDialog(QDialog):
def _skip(self): def _skip(self):
if self.review_mode and self.current_dup_pair: 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.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 # Borramos los hashes para que el detector las trate como imágenes nuevas
# y fuerce una nueva comparación en el siguiente escaneo. # y fuerce una nueva comparación en el siguiente escaneo.
# Usamos clear_relationships=False para no perder otras posibles coincidencias ya marcadas. # Usamos clear_relationships=False para no perder otras posibles
self.cache.remove_hash_for_path(self.current_dup_pair.path1, clear_relationships=False) # coincidencias ya marcadas.
self.cache.remove_hash_for_path(self.current_dup_pair.path2, clear_relationships=False) 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) self._handle_action(skip=False, permanent=False)
else: else:
self._handle_action(skip=True) self._handle_action(skip=True)
@@ -732,27 +815,35 @@ class DuplicateManagerDialog(QDialog):
# Remove all pairs containing this path from the persistent pending DB # Remove all pairs containing this path from the persistent pending DB
# because the file will be gone. # 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] 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: for p in pairs_to_unmark:
self.cache.mark_as_pending(p.path1, p.path2, False) self.cache.mark_as_pending(p.path1, p.path2, False)
self.main_win.delete_file_by_path(delete_path, permanent=permanent) # Use default setting self.main_win.delete_file_by_path(delete_path, permanent=permanent)
if os.path.exists(delete_path): if os.path.exists(delete_path):
QMessageBox.warning(self, UITexts.ERROR, UITexts.ERROR_DELETING_FILE.format(delete_path)) QMessageBox.warning(
self, UITexts.ERROR,
UITexts.ERROR_DELETING_FILE.format(delete_path))
return return
# Remove all pairs containing this path because it no longer exists # 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] self.duplicates = [d for d in self.duplicates
if d.path1 != delete_path and d.path2 != delete_path]
else: else:
# Skip or KeepBoth: # Skip or KeepBoth:
if not skip: # "Keep Both" case if not skip: # "Keep Both" case
# It's no longer pending, it's an exception (already marked in _keep_both) # It's no longer pending, it's an exception (already marked in
self.cache.mark_as_pending(current_pair.path1, current_pair.path2, False) # _keep_both)
# Note: if it's "Skip", we do NOT remove it from pending DB, so it stays there for next time. 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): if 0 <= original_index < len(self.duplicates):
self.duplicates.pop(original_index) self.duplicates.pop(original_index)
# Repopulate list widget to ensure all indices are correct and counter is updated # Repopulate list widget to ensure all indices are correct and counter is
# updated
self._populate_list() self._populate_list()
# Try to restore selection to same position (or last item) # Try to restore selection to same position (or last item)

View File

@@ -424,7 +424,7 @@ class FaceCanvas(QLabel):
self.zoom_indicator_point = None self.zoom_indicator_point = None
self.zoom_indicator_timer = QTimer(self) self.zoom_indicator_timer = QTimer(self)
self.zoom_indicator_timer.setSingleShot(True) self.zoom_indicator_timer.setSingleShot(True)
self.zoom_indicator_timer.setInterval(500) # Show for 500ms self.zoom_indicator_timer.setInterval(500) # Show for 500ms
self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator) self.zoom_indicator_timer.timeout.connect(self._clear_zoom_indicator)
self.crop_rect = QRect() self.crop_rect = QRect()
self.crop_handle = None self.crop_handle = None
@@ -636,13 +636,15 @@ class FaceCanvas(QLabel):
# Draw zoom indicator # Draw zoom indicator
if self.zoom_indicator_point: if self.zoom_indicator_point:
painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair painter.setPen(QPen(QColor(255, 255, 0), 2)) # Yellow crosshair
painter.drawLine(self.zoom_indicator_point.x() - 10, painter.drawLine(self.zoom_indicator_point.x() - 10,
self.zoom_indicator_point.y(), self.zoom_indicator_point.y(),
self.zoom_indicator_point.x() + 10, self.zoom_indicator_point.x() + 10,
self.zoom_indicator_point.y()) self.zoom_indicator_point.y())
painter.drawLine(self.zoom_indicator_point.x(), self.zoom_indicator_point.y() - 10, painter.drawLine(self.zoom_indicator_point.x(),
self.zoom_indicator_point.x(), self.zoom_indicator_point.y() + 10) self.zoom_indicator_point.y() - 10,
self.zoom_indicator_point.x(),
self.zoom_indicator_point.y() + 10)
def _hit_test(self, pos): def _hit_test(self, pos):
"""Determines if the mouse is over a name, handle, or body.""" """Determines if the mouse is over a name, handle, or body."""
@@ -1145,7 +1147,8 @@ class ZoomManager(QObject):
def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None): def zoom(self, factor=1.1, reset=False, focus_point=None, absolute_factor=None):
"""Applies zoom to the image, centering on focus_point if provided.""" """Applies zoom to the image, centering on focus_point if provided."""
if not self.viewer.controller or self.viewer.controller.pixmap_original.isNull(): if not self.viewer.controller or \
self.viewer.controller.pixmap_original.isNull():
return return
c_point = None c_point = None
@@ -1155,9 +1158,10 @@ class ZoomManager(QObject):
self.viewer.update_view(resize_win=True) self.viewer.update_view(resize_win=True)
if self.viewer.canvas: if self.viewer.canvas:
c_point = self.viewer.canvas.rect().center() c_point = self.viewer.canvas.rect().center()
elif absolute_factor is not None: # New: set absolute zoom factor elif absolute_factor is not None: # New: set absolute zoom factor
self.viewer.controller.zoom_factor = absolute_factor self.viewer.controller.zoom_factor = absolute_factor
self.viewer.update_view(resize_win=False) # Don't resize window for sync zoom # Don't resize window for sync zoom
self.viewer.update_view(resize_win=False)
if focus_point is not None and self.viewer.canvas: if focus_point is not None and self.viewer.canvas:
scroll_area = self.viewer.scroll_area scroll_area = self.viewer.scroll_area
viewport = scroll_area.viewport() viewport = scroll_area.viewport()
@@ -1171,7 +1175,8 @@ class ZoomManager(QObject):
if focus_point is None: if focus_point is None:
v_point = viewport.rect().center() v_point = viewport.rect().center()
else: else:
# focus_point es relativo al widget self.viewer (ImageViewer o ImagePane) # focus_point es relativo al widget self.viewer (ImageViewer o
# ImagePane)
v_point = viewport.mapFrom(self.viewer, focus_point) v_point = viewport.mapFrom(self.viewer, focus_point)
# 2. Mapear el punto de enfoque a coordenadas del canvas antes del zoom # 2. Mapear el punto de enfoque a coordenadas del canvas antes del zoom
@@ -1181,7 +1186,8 @@ class ZoomManager(QObject):
# Aplicar la actualización (esto redimensiona el canvas) # Aplicar la actualización (esto redimensiona el canvas)
self.viewer.update_view(resize_win=(not self.viewer.isFullScreen())) self.viewer.update_view(resize_win=(not self.viewer.isFullScreen()))
# 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el cursor # 3. Ajustar las barras de desplazamiento para mantener el píxel bajo el
# cursor
scroll_area.horizontalScrollBar().setValue( scroll_area.horizontalScrollBar().setValue(
int(c_point.x() * factor - v_point.x())) int(c_point.x() * factor - v_point.x()))
scroll_area.verticalScrollBar().setValue( scroll_area.verticalScrollBar().setValue(
@@ -1721,7 +1727,9 @@ class ImageViewer(QWidget):
for pane in self.panes: for pane in self.panes:
if pane != self.active_pane: if pane != self.active_pane:
QTimer.singleShot(0, lambda p=pane, x=x_pct, y=y_pct: p.set_scroll_relative(x, y)) QTimer.singleShot(
0, lambda p=pane, x=x_pct,
y=y_pct: p.set_scroll_relative(x, y))
def update_grid_layout(self): def update_grid_layout(self):
# Clear layout # Clear layout
@@ -1761,7 +1769,8 @@ class ImageViewer(QWidget):
new_idx = (start_idx + i + 1) % len(img_list) new_idx = (start_idx + i + 1) % len(img_list)
pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load pane = self.add_pane(img_list, new_idx, None, 0) # Metadata will load
if self.panes_linked and self.active_pane: if self.panes_linked and self.active_pane:
pane.controller.zoom_factor = self.active_pane.controller.zoom_factor pane.controller.zoom_factor = \
self.active_pane.controller.zoom_factor
pane.load_and_fit_image() pane.load_and_fit_image()
else: else:
# Remove panes (keep active if possible, else keep first) # Remove panes (keep active if possible, else keep first)
@@ -1779,7 +1788,7 @@ class ImageViewer(QWidget):
# sizing # sizing
QTimer.singleShot( QTimer.singleShot(
0, lambda: self.active_pane.update_view(resize_win=True)) 0, lambda: self.active_pane.update_view(resize_win=True))
self.adjustSize() # Ajustar el tamaño de la ventana después de añadir/eliminar paneles self.adjustSize()
def toggle_link_panes(self): def toggle_link_panes(self):
"""Toggles the synchronized zoom/scroll for comparison mode.""" """Toggles the synchronized zoom/scroll for comparison mode."""

View File

@@ -17,11 +17,12 @@ except ImportError:
exiv2 = None exiv2 = None
HAVE_EXIV2 = False HAVE_EXIV2 = False
_app_modified_callback = None
from utils import preserve_mtime from utils import preserve_mtime
from constants import RATING_XATTR_NAME, XATTR_NAME from constants import RATING_XATTR_NAME, XATTR_NAME
_app_modified_callback = None
MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating']) MetadataResult = collections.namedtuple('MetadataResult', ['tags', 'rating'])
EMPTY_METADATA = MetadataResult([], 0) EMPTY_METADATA = MetadataResult([], 0)
@@ -30,11 +31,13 @@ def set_app_modified_callback(callback):
global _app_modified_callback global _app_modified_callback
_app_modified_callback = callback _app_modified_callback = callback
def mark_app_modified(path): def mark_app_modified(path):
"""Triggers the application-modified callback for a path.""" """Triggers the application-modified callback for a path."""
if _app_modified_callback: if _app_modified_callback:
_app_modified_callback(path) _app_modified_callback(path)
def notify_baloo(path): def notify_baloo(path):
""" """
Notifies the Baloo file indexer about a file change using DBus. Notifies the Baloo file indexer about a file change using DBus.

View File

@@ -15,7 +15,6 @@ Dependencies:
- utils.preserve_mtime: A utility to prevent file modification times from - utils.preserve_mtime: A utility to prevent file modification times from
changing during metadata writes. changing during metadata writes.
""" """
from importlib.resources import path
import os import os
import re import re
from utils import preserve_mtime from utils import preserve_mtime