This commit is contained in:
Ignacio Serantes
2026-03-31 23:35:57 +02:00
parent ff7c1aa373
commit cb751b2970
14 changed files with 2431 additions and 119 deletions

View File

@@ -14,7 +14,7 @@ Classes:
MainWindow: The main application window containing the thumbnail grid and docks.
"""
__appname__ = "BagheeraView"
__version__ = "0.9.15"
__version__ = "0.9.16"
__author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net"
__license__ = "LGPL"
@@ -53,21 +53,28 @@ from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from pathlib import Path
from constants import (
APP_CONFIG, CONFIG_PATH, DEFAULT_GLOBAL_SHORTCUTS, DEFAULT_LANGUAGE,
APP_CONFIG, CACHE_PATH, CONFIG_PATH, DEFAULT_GLOBAL_SHORTCUTS, DEFAULT_LANGUAGE,
DEFAULT_VIEWER_SHORTCUTS, GLOBAL_ACTIONS, HISTORY_PATH, ICON_THEME,
ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR,
PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES,
ICON_THEME_FALLBACK, SCANNER_GENERATE_SIZES, IMAGE_MIME_TYPES, IMAGE_EXTENSIONS,
LAYOUTS_DIR, FAVORITES_PATH, PROG_AUTHOR,
PROG_NAME, PROG_VERSION, RATING_XATTR_NAME,
SCANNER_SETTINGS_DEFAULTS, SUPPORTED_LANGUAGES, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_BG_COLOR_DEFAULT, THUMBNAILS_DEFAULT_SIZE, VIEWER_ACTIONS,
THUMBNAILS_FILENAME_COLOR_DEFAULT, THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT,
HAVE_IMAGEHASH, FACES_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT,
THUMBNAILS_TAGS_COLOR_DEFAULT, THUMBNAILS_RATING_COLOR_DEFAULT,
THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT,
THUMBNAIL_SIZES, XATTR_NAME, UITexts
THUMBNAIL_SIZES, XATTR_NAME, UITexts, save_app_config
)
import constants
from settings import SettingsDialog
if HAVE_IMAGEHASH:
from duplicatecache import DuplicateCache, DuplicateDetector
from duplicatedialog import DuplicateManagerDialog
else:
DuplicateCache = None
DuplicateDetector = None
from imagescanner import (CacheCleaner, ImageScanner, ThumbnailCache,
ThumbnailGenerator, ThreadPoolManager)
from imageviewer import ImageViewer
@@ -367,8 +374,8 @@ class AppShortcutController(QObject):
"save_layout": self.main_win.save_layout,
"load_layout": self.main_win.load_layout_dialog,
"open_folder": self.main_win.open_current_folder,
"move_to_trash": lambda:
self.main_win.delete_current_image(permanent=False),
"move_to_trash":
lambda: self.main_win.delete_current_image(permanent=None),
"delete_permanently":
lambda: self.main_win.delete_current_image(permanent=True),
"rename_image": self._rename_image,
@@ -975,24 +982,25 @@ class ThumbnailSortFilterProxyModel(QSortFilterProxyModel):
class MainWindow(QMainWindow):
"""
The main application window, which serves as the central hub for browsing
and managing images.
and managing images, including duplicate detection.
It features a virtualized thumbnail grid for performance, a dockable sidebar
for metadata editing and filtering, and manages the lifecycle of background
scanners and individual image viewer windows.
"""
def __init__(self, cache, args, thread_pool_manager):
"""
Initializes the MainWindow.
def __init__(self, cache, args, thread_pool_manager, duplicate_cache):
"""Initializes the MainWindow.
Args:
cache (ThumbnailCache): The shared thumbnail cache instance.
args (list): Command-line arguments passed to the application.
thread_pool_manager (ThreadPoolManager): The shared thread pool manager.
duplicate_cache (DuplicateCache): The shared duplicate cache instance.
"""
super().__init__()
self.cache = cache
self.duplicate_cache = duplicate_cache
self.setWindowTitle(f"{PROG_NAME} v{PROG_VERSION}")
self.set_app_icon()
@@ -1094,13 +1102,26 @@ class MainWindow(QMainWindow):
# Bottom bar with status and controls
bot = QHBoxLayout()
self.status_lbl = QLabel(UITexts.READY)
bot.addWidget(self.status_lbl)
self.btn_cancel_duplicates = QPushButton()
self.btn_cancel_duplicates.setIcon(QIcon.fromTheme("process-stop"))
self.btn_cancel_duplicates.setFixedSize(22, 22)
self.btn_cancel_duplicates.setToolTip(UITexts.CANCEL)
self.btn_cancel_duplicates.setFocusPolicy(Qt.NoFocus)
self.btn_cancel_duplicates.hide()
self.btn_cancel_duplicates.clicked.connect(self.cancel_duplicate_detection)
bot.addWidget(self.btn_cancel_duplicates)
self.progress_bar = CircularProgressBar(self)
self.progress_bar.hide()
bot.addWidget(self.progress_bar)
self.status_counter_lbl = QLabel("")
self.status_counter_lbl.hide()
bot.addWidget(self.status_counter_lbl)
self.status_lbl = QLabel(UITexts.READY)
bot.addWidget(self.status_lbl)
self.fs_watcher_status_lbl = QLabel()
self.fs_watcher_status_lbl.setToolTip(UITexts.FS_WATCHER_TOOLTIP)
self.fs_watcher_status_lbl.hide()
@@ -1306,6 +1327,7 @@ class MainWindow(QMainWindow):
self.rebuild_timer.timeout.connect(self.rebuild_view)
# Timer to resume scanning after user interaction stops
self.duplicate_detector = None # Worker for duplicate detection
self.resume_scan_timer = QTimer(self)
self.resume_scan_timer.setSingleShot(True)
self.resume_scan_timer.setInterval(400)
@@ -1358,7 +1380,7 @@ class MainWindow(QMainWindow):
self._apply_global_stylesheet()
# Set the initial thumbnail generation tier based on the loaded config size
self._current_thumb_tier = self._get_tier_for_size(self.current_thumb_size)
constants.SCANNER_GENERATE_SIZES = [self._current_thumb_tier]
# SCANNER_GENERATE_SIZES = [self._current_thumb_tier]
if hasattr(self, 'history_tab'):
self.history_tab.refresh_list()
@@ -1718,7 +1740,7 @@ class MainWindow(QMainWindow):
size_mb = size / (1024 * 1024)
disk_cache_size_mb = 0
disk_cache_path = os.path.join(constants.CACHE_PATH, "data.mdb")
disk_cache_path = os.path.join(CACHE_PATH, "data.mdb")
if os.path.exists(disk_cache_path):
disk_cache_size_bytes = os.path.getsize(disk_cache_path)
disk_cache_size_mb = disk_cache_size_bytes / (1024 * 1024)
@@ -1736,6 +1758,36 @@ class MainWindow(QMainWindow):
menu.addSeparator()
duplicates_menu = menu.addMenu(QIcon.fromTheme("edit-find-replace"), UITexts.MENU_DUPLICATES)
duplicates_menu.setEnabled(HAVE_IMAGEHASH)
detect_current_action = duplicates_menu.addAction(UITexts.MENU_DETECT_CURRENT_SEARCH)
detect_current_action.triggered.connect(self.start_duplicate_detection)
detect_all_action = duplicates_menu.addAction(UITexts.MENU_DETECT_ALL)
detect_all_action.triggered.connect(self.detect_all_duplicates)
force_full_action = duplicates_menu.addAction(UITexts.MENU_FORCE_FULL_ANALYSIS)
force_full_action.triggered.connect(lambda: self.start_duplicate_detection(force_full=True))
review_ignored_action = duplicates_menu.addAction(UITexts.MENU_REVIEW_IGNORED)
review_ignored_action.triggered.connect(self.review_ignored_duplicates)
duplicates_menu.addSeparator()
clean_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("edit-clear-all"),
UITexts.MENU_CLEAN_UP_HASHES)
clean_hashes_action.triggered.connect(self.clean_duplicate_hashes)
if self.duplicate_cache:
count, size_bytes = self.duplicate_cache.get_hash_stats()
size_mb = size_bytes / (1024 * 1024)
clear_hashes_action = duplicates_menu.addAction(QIcon.fromTheme("user-trash-full"),
UITexts.MENU_CLEAR_HASHES.format(count, size_mb))
clear_hashes_action.triggered.connect(self.clear_duplicate_hashes)
menu.addSeparator()
show_shortcuts_action = menu.addAction(QIcon.fromTheme("help-keys"),
UITexts.MENU_SHOW_SHORTCUTS)
show_shortcuts_action.triggered.connect(self.show_shortcuts_help)
@@ -1770,6 +1822,89 @@ class MainWindow(QMainWindow):
menu.exec(self.menu_btn.mapToGlobal(QPoint(0, self.menu_btn.height())))
def detect_all_duplicates(self):
"""Gathers files from whitelist (respecting blacklist) and runs detector."""
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
paths = self._gather_files_for_duplicates()
finally:
QApplication.restoreOverrideCursor()
if paths is None:
QMessageBox.warning(self, UITexts.WARNING, "Whitelist is empty. Please configure it in Settings.")
return
if not paths:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
return
self.start_duplicate_detection(custom_paths=paths)
def _gather_files_for_duplicates(self):
"""Helper to collect image paths based on whitelist and blacklist settings."""
whitelist_str = APP_CONFIG.get("duplicate_whitelist", "")
blacklist_str = APP_CONFIG.get("duplicate_blacklist", "")
whitelist = [os.path.abspath(os.path.expanduser(p.strip())) for p in whitelist_str.split(',') if p.strip()]
blacklist = [os.path.abspath(os.path.expanduser(p.strip())) for p in blacklist_str.split(',') if p.strip()]
if not whitelist:
return None
all_paths = []
blacklist_set = set(blacklist)
for root_path in whitelist:
if not os.path.exists(root_path):
continue
for root, dirs, files in os.walk(root_path):
abs_root = os.path.abspath(root)
# Prune dirs to stop walking into blacklisted paths
dirs[:] = [d for d in dirs if os.path.join(abs_root, d) not in blacklist_set]
if abs_root in blacklist_set:
continue
for f in files:
if os.path.splitext(f)[1].lower() in IMAGE_EXTENSIONS:
full_p = os.path.join(abs_root, f)
if full_p not in blacklist_set:
all_paths.append(full_p)
return all_paths
def clean_duplicate_hashes(self):
if self.duplicate_cache:
count = self.duplicate_cache.clean_stale_hashes()
self.status_lbl.setText(f"Cleaned up {count} stale hash entries.")
def clear_duplicate_hashes(self):
if not self.duplicate_cache:
return
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setWindowTitle(UITexts.CONFIRM_CLEAR_HASHES_TITLE)
confirm.setText(UITexts.CONFIRM_CLEAR_HASHES_TEXT)
confirm.setInformativeText(UITexts.CONFIRM_CLEAR_HASHES_INFO)
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() != QMessageBox.Yes:
return
self.duplicate_cache.clear_hashes()
self.status_lbl.setText("Duplicate hash cache cleared.")
def review_ignored_duplicates(self):
if not self.duplicate_cache:
return
ignored = self.duplicate_cache.get_all_exceptions()
if not ignored:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE, UITexts.DUPLICATE_NONE_FOUND)
return
dialog = DuplicateManagerDialog(ignored, self.duplicate_cache, self, review_mode=True)
dialog.show()
def show_about_dialog(self):
"""Shows the 'About' dialog box."""
QMessageBox.about(self, UITexts.MENU_ABOUT_TITLE.format(PROG_NAME),
@@ -1785,27 +1920,27 @@ class MainWindow(QMainWindow):
if dlg.exec():
# Update settings that affect the main window immediately
new_interval = APP_CONFIG.get("thumbnails_refresh_interval",
constants.THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
self.thumbnails_refresh_timer.setInterval(new_interval)
new_max_tags = APP_CONFIG.get("tags_menu_max_items",
constants.TAGS_MENU_MAX_ITEMS_DEFAULT)
TAGS_MENU_MAX_ITEMS_DEFAULT)
if self.mru_tags.maxlen != new_max_tags:
# Recreate deque with new size, preserving content
self.mru_tags = deque(self.mru_tags, maxlen=new_max_tags)
new_max_faces = APP_CONFIG.get("faces_menu_max_items",
constants.FACES_MENU_MAX_ITEMS_DEFAULT)
FACES_MENU_MAX_ITEMS_DEFAULT)
if len(self.face_names_history) > new_max_faces:
self.face_names_history = self.face_names_history[:new_max_faces]
new_max_bodies = APP_CONFIG.get("body_menu_max_items",
constants.FACES_MENU_MAX_ITEMS_DEFAULT)
FACES_MENU_MAX_ITEMS_DEFAULT)
if len(self.body_names_history) > new_max_bodies:
self.body_names_history = self.body_names_history[:new_max_bodies]
new_bg_color = APP_CONFIG.get("thumbnails_bg_color",
constants.THUMBNAILS_BG_COLOR_DEFAULT)
THUMBNAILS_BG_COLOR_DEFAULT)
self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};")
# Reload filmstrip position so it applies to new viewers
@@ -1876,6 +2011,12 @@ class MainWindow(QMainWindow):
def perform_shutdown(self):
"""Performs cleanup operations before the application closes."""
self.is_cleaning = True
# Save configuration early if visible, as per user request.
# This ensures persistence even if subsequent cleanup hangs.
if self.isVisible():
self.save_config()
self.fs_watcher.stop()
# 1. Stop all worker threads interacting with the cache
@@ -1884,6 +2025,8 @@ class MainWindow(QMainWindow):
self.scanner.stop()
if self.thumbnail_generator and self.thumbnail_generator.isRunning():
self.thumbnail_generator.stop()
if self.duplicate_detector and self.duplicate_detector.isRunning():
self.duplicate_detector.stop()
# Create a list of threads to wait for
threads_to_wait = []
@@ -1891,10 +2034,11 @@ class MainWindow(QMainWindow):
threads_to_wait.append(self.scanner)
if self.thumbnail_generator and self.thumbnail_generator.isRunning():
threads_to_wait.append(self.thumbnail_generator)
if hasattr(self, 'cache_cleaner') and self.cache_cleaner and \
self.cache_cleaner.isRunning():
self.cache_cleaner.stop()
if hasattr(self, 'cache_cleaner') and self.cache_cleaner \
and self.cache_cleaner.isRunning():
threads_to_wait.append(self.cache_cleaner)
if self.duplicate_detector and self.duplicate_detector.isRunning():
threads_to_wait.append(self.duplicate_detector)
# Wait for them to finish while keeping the UI responsive
if threads_to_wait:
@@ -1903,14 +2047,20 @@ class MainWindow(QMainWindow):
for thread in threads_to_wait:
while thread.isRunning():
QApplication.processEvents()
if QApplication.instance(): # Check if QApplication is still valid
QApplication.processEvents()
QThread.msleep(50) # Prevent high CPU usage
# Ensure all QRunnables in the shared thread pool are finished
if self.thread_pool_manager:
self.thread_pool_manager.get_pool().waitForDone()
if self.duplicate_cache:
self.duplicate_cache.lmdb_close()
QApplication.restoreOverrideCursor()
# 2. Close the cache safely now that no threads are using it
self.cache.lmdb_close()
self.save_config()
def closeEvent(self, event):
"""Handles the main window close event to ensure graceful shutdown."""
@@ -2224,30 +2374,63 @@ class MainWindow(QMainWindow):
if not selected_indexes:
return
# For now, only handle single deletion
path = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
paths = []
for idx in selected_indexes:
path = self.proxy_model.data(idx, PATH_ROLE)
if path and path not in paths:
paths.append(path)
if permanent:
# Confirm permanent deletion
if not paths:
return
# Determine actual permanent status based on setting if not explicitly passed
_permanent = permanent if permanent is not None \
else not APP_CONFIG.get("default_delete_to_trash", True)
if _permanent:
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setWindowTitle(UITexts.CONFIRM_DELETE_TITLE)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
if len(paths) == 1:
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(paths[0])))
else:
confirm.setText(f"Are you sure you want to permanently delete {len(paths)} images?")
confirm.setInformativeText("This action CANNOT be undone.")
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() != QMessageBox.Yes:
return
self.thumbnail_view.setUpdatesEnabled(False)
try:
if permanent:
for path in paths:
self.delete_file_by_path(path, _permanent)
finally:
self.thumbnail_view.setUpdatesEnabled(True)
self.rebuild_view()
def delete_file_by_path(self, path, permanent=None):
"""
Deletes a file and updates the application state.
Logic extracted from delete_current_image for reuse.
Args:
path (str): The path to the file to delete.
permanent (bool, optional): If True, deletes permanently. If False,
sends to trash. If None, uses the
'default_delete_to_trash' setting.
Defaults to None.
"""
_permanent = permanent if permanent is not None \
else not APP_CONFIG.get("default_delete_to_trash", True)
try:
if _permanent:
os.remove(path)
else:
# Use 'gio trash' for moving to trash can on Linux
subprocess.run(["gio", "trash", path])
# TODO: Handle multi-selection delete
# Notify open viewers of the deletion
for w in QApplication.topLevelWidgets():
if isinstance(w, ImageViewer):
@@ -2260,13 +2443,15 @@ class MainWindow(QMainWindow):
except (ValueError, RuntimeError):
pass # Viewer might be closing or list out of sync
source_index = self.proxy_model.mapToSource(selected_indexes[0])
if source_index.isValid():
self.thumbnail_model.removeRow(source_index.row())
if path in self._path_to_model_index:
p_idx = self._path_to_model_index[path]
if p_idx.isValid():
self.thumbnail_model.removeRow(p_idx.row())
if path in self._path_to_model_index:
del self._path_to_model_index[path]
self.duplicate_cache.remove_hash_for_path(path)
# Remove from found_items_data to ensure consistency
self.found_items_data = [x for x in self.found_items_data if x[0] != path]
self._known_paths.discard(path)
@@ -3626,6 +3811,24 @@ class MainWindow(QMainWindow):
viewer.show()
return viewer
def open_comparison_viewer(self, paths):
"""
Opens an ImageViewer specifically for comparing a set of paths.
"""
if not paths:
return
viewer = ImageViewer(self.cache, paths, 0, None, 0, self)
self._setup_viewer_sync(viewer)
self.viewers.append(viewer)
viewer.destroyed.connect(
lambda obj=viewer: self.viewers.remove(obj) if obj in self.viewers else None)
if len(paths) > 1:
viewer.set_comparison_mode(len(paths))
viewer.show()
return viewer
def load_full_history(self):
"""Loads the persistent browsing/search history from its JSON file."""
if os.path.exists(HISTORY_PATH):
@@ -3779,7 +3982,7 @@ class MainWindow(QMainWindow):
# 1. Update the list of sizes for the main scanner to generate for
# any NEW images (e.g., from scrolling down). It will now only
# generate the tier needed for the current view.
constants.SCANNER_GENERATE_SIZES = [new_tier]
# SCANNER_GENERATE_SIZES = [new_tier]
# 2. For all images ALREADY loaded, start a background job to
# generate the newly required thumbnail size. This is interruptible.
@@ -3961,8 +4164,10 @@ class MainWindow(QMainWindow):
try:
with open(CONFIG_PATH, 'r') as f:
d = json.load(f)
except Exception:
pass # Ignore errors in config file
except Exception as e:
# Log the error to help diagnose why config might not be loading
print(f"Error loading config file {CONFIG_PATH}: {e}")
# import traceback; traceback.print_exc() # Uncomment for full traceback
self.history = d.get("history", [])
self.current_thumb_size = d.get("thumb_size",
@@ -4074,12 +4279,10 @@ class MainWindow(QMainWindow):
g_shortcuts_list.append([[k, mod_int], [act, ignore, desc, cat]])
APP_CONFIG["global_shortcuts"] = g_shortcuts_list
# Save geometry only if the window is visible
if self.isVisible():
APP_CONFIG["geometry"] = {"x": self.x(), "y": self.y(),
"w": self.width(), "h": self.height()}
APP_CONFIG["geometry"] = {"x": self.x(), "y": self.y(),
"w": self.width(), "h": self.height()}
constants.save_app_config()
save_app_config()
def resizeEvent(self, e):
"""Handles window resize events to trigger a debounced grid refresh."""
@@ -4200,6 +4403,12 @@ class MainWindow(QMainWindow):
self.proxy_model.data(selected_indexes[0], PATH_ROLE))
self.populate_open_with_submenu(open_submenu, full_path)
# New action: Open in Fullscreen Viewer
action_open_fullscreen = open_submenu.addAction(
QIcon.fromTheme("view-fullscreen"),
UITexts.CONTEXT_MENU_OPEN_FULLSCREEN_VIEWER)
action_open_fullscreen.triggered.connect(lambda: self.open_in_fullscreen_viewer(selected_indexes[0]))
path = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
action_open_location = menu.addAction(QIcon.fromTheme("folder-search"),
UITexts.CONTEXT_MENU_OPEN_SEARCH_LOCATION)
@@ -4239,10 +4448,10 @@ class MainWindow(QMainWindow):
action_rotate_cw.triggered.connect(lambda: self.rotate_current_image(90))
menu.addSeparator()
# The 'move_to_trash' action now uses the configurable default behavior
add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_TRASH, "user-trash",
"move_to_trash",
lambda: self.delete_current_image(permanent=False))
lambda: self.delete_current_image(permanent=None))
add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_DELETE, "edit-delete",
"delete_permanently",
lambda: self.delete_current_image(permanent=True))
@@ -4475,6 +4684,12 @@ class MainWindow(QMainWindow):
full_path, initial_tags=tags, initial_rating=rating, parent=self)
dlg.exec()
def open_in_fullscreen_viewer(self, proxy_index):
"""Opens the selected image in a new ImageViewer in fullscreen mode."""
viewer = self.open_viewer(proxy_index)
if viewer:
viewer.toggle_fullscreen()
def clear_thumbnail_cache(self):
"""Clears the entire in-memory and on-disk thumbnail cache."""
confirm = QMessageBox(self)
@@ -4505,7 +4720,7 @@ class MainWindow(QMainWindow):
for p in paths:
p = os.path.abspath(p)
if os.path.exists(p) and p not in self._known_paths:
if os.path.splitext(p)[1].lower() in constants.IMAGE_EXTENSIONS:
if os.path.splitext(p)[1].lower() in IMAGE_EXTENSIONS:
valid_new_items.append(p)
if not valid_new_items:
@@ -4584,8 +4799,8 @@ class MainWindow(QMainWindow):
old_path = os.path.abspath(old_path)
new_path = os.path.abspath(new_path)
is_old_img = os.path.splitext(old_path)[1].lower() in constants.IMAGE_EXTENSIONS
is_new_img = os.path.splitext(new_path)[1].lower() in constants.IMAGE_EXTENSIONS
is_old_img = os.path.splitext(old_path)[1].lower() in IMAGE_EXTENSIONS
is_new_img = os.path.splitext(new_path)[1].lower() in IMAGE_EXTENSIONS
if is_old_img and is_new_img:
if old_path in self._known_paths:
@@ -4665,6 +4880,7 @@ class MainWindow(QMainWindow):
self._known_paths.add(new_path)
# Clean up group cache since the key (path) has changed
self.duplicate_cache.rename_entry(old_path, new_path)
cache_key = (old_path, item_data[2], item_data[4])
if cache_key in self._group_info_cache:
del self._group_info_cache[cache_key]
@@ -4821,7 +5037,7 @@ class MainWindow(QMainWindow):
# Only save and show message if the language actually changed
if new_lang != APP_CONFIG.get("language", DEFAULT_LANGUAGE):
APP_CONFIG["language"] = new_lang
constants.save_app_config()
save_app_config()
# Inform user that a restart is needed for the change to take effect
msg_box = QMessageBox(self)
@@ -4832,6 +5048,85 @@ class MainWindow(QMainWindow):
msg_box.setStandardButtons(QMessageBox.Ok)
msg_box.exec()
def start_duplicate_detection(self, force_full=False, custom_paths=None):
"""Initiates the duplicate image detection process."""
if self.duplicate_detector and self.duplicate_detector.isRunning():
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE,
UITexts.DUPLICATE_ALREADY_RUNNING)
return
# Get all image paths currently known to the application or provided list
paths_to_scan = custom_paths if custom_paths is not None else self.get_all_image_paths()
if not paths_to_scan:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE,
UITexts.DUPLICATE_NO_IMAGES)
return
# Get settings from APP_CONFIG
method = APP_CONFIG.get("duplicate_method", "histogram_hashing")
threshold = APP_CONFIG.get("duplicate_threshold", 90)
self.duplicate_detector = DuplicateDetector(
paths_to_scan, self.duplicate_cache, self.thread_pool_manager, method, threshold, force_full=force_full)
self.duplicate_detector.progress_update.connect(self.on_duplicate_detection_progress)
self.duplicate_detector.duplicates_found.connect(self.on_duplicates_found)
self.duplicate_detector.detection_finished.connect(self.on_duplicate_detection_finished)
self.progress_bar.setValue(0)
self.progress_bar.setCustomColor(None)
self.progress_bar.show()
self.btn_cancel_duplicates.show()
self.status_counter_lbl.show()
self.status_lbl.setText(UITexts.DUPLICATE_STARTING)
self.duplicate_detector.start()
def on_duplicate_detection_progress(self, current, total, message):
"""Updates the UI with progress during duplicate detection."""
percent = int((current / total) * 100) if total > 0 else 0
# Visual differentiation of detection phases using colors:
if percent < 50:
# Phase 1: Hashing images (Blue)
self.progress_bar.setCustomColor(QColor("#3498db"))
else:
# Phase 2: Mathematical comparison (Orange/Amber)
self.progress_bar.setCustomColor(QColor("#f39c12"))
self.progress_bar.setValue(percent)
self.status_counter_lbl.setText(f"[{current}/{total}]")
self.status_lbl.setText(message)
def on_duplicates_found(self, duplicates):
"""Handles the list of found duplicate image pairs."""
if not duplicates:
QMessageBox.information(self, UITexts.DUPLICATE_DETECTION_TITLE,
UITexts.DUPLICATE_NONE_FOUND)
return
dialog = DuplicateManagerDialog(duplicates, self.duplicate_cache, self)
dialog.show()
def on_duplicate_detection_finished(self):
"""Cleans up after duplicate detection is complete."""
self.progress_bar.setValue(100)
self.progress_bar.setCustomColor(QColor("#2ecc71")) # Green for success
self.hide_progress_timer.start(2000) # Hide after 2 seconds
self.btn_cancel_duplicates.hide()
self.status_counter_lbl.hide()
self.status_lbl.setText(UITexts.DUPLICATE_FINISHED)
self.duplicate_detector = None
def cancel_duplicate_detection(self):
"""Stops the duplicate detection thread."""
if self.duplicate_detector and self.duplicate_detector.isRunning():
self.duplicate_detector.stop()
self.duplicate_detector.wait()
self.status_lbl.setText(UITexts.CANCEL)
self.btn_cancel_duplicates.hide()
self.status_counter_lbl.hide()
def main():
"""The main entry point for the Bagheera Image Viewer application."""
@@ -4840,16 +5135,16 @@ def main():
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
QPixmapCache.setCacheLimit(104857600) # Old value: 102400
duplicate_cache = DuplicateCache() if HAVE_IMAGEHASH else None
thread_pool_manager = ThreadPoolManager()
cache = ThumbnailCache()
args = [a for a in sys.argv[1:] if a != "--x11"]
if args:
path = " ".join(args).strip()
if path.startswith("file:/"):
path = path[6:]
win = MainWindow(cache, args, thread_pool_manager)
win = MainWindow(cache, args, thread_pool_manager, duplicate_cache)
app.installEventFilter(win.shortcut_controller)
sys.exit(app.exec())