v0.9.16
This commit is contained in:
405
bagheeraview.py
405
bagheeraview.py
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user