From a402828d1a842e886bcde7214ba482c75accc5d4 Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Sun, 22 Mar 2026 18:16:51 +0100 Subject: [PATCH] First commit --- README.md | 83 + bagheera_query_parser_lib | 1 + bagheera_search_lib | 1 + bagheeraview | 1 + bagheeraview.desktop | 23 + bagheeraview.py | 4313 ++++++++++++++++++++++++++++++++++++ bagheeraview_devel.desktop | 23 + baloo_tools | 1 + build.sh | 47 + changelog.txt | 256 +++ constants.py | 1598 +++++++++++++ imagecontroller.py | 757 +++++++ imagescanner.py | 1635 ++++++++++++++ imageviewer.py | 2703 ++++++++++++++++++++++ metadatamanager.py | 138 ++ propertiesdialog.py | 403 ++++ pyproject.toml | 61 + requirements.txt | 7 + settings.py | 1014 +++++++++ setup.py | 88 + utils.py | 45 + widgets.py | 1402 ++++++++++++ xmpmanager.py | 168 ++ 23 files changed, 14768 insertions(+) create mode 100644 README.md create mode 120000 bagheera_query_parser_lib create mode 120000 bagheera_search_lib create mode 120000 bagheeraview create mode 100755 bagheeraview.desktop create mode 100755 bagheeraview.py create mode 100755 bagheeraview_devel.desktop create mode 120000 baloo_tools create mode 100755 build.sh create mode 100644 changelog.txt create mode 100644 constants.py create mode 100644 imagecontroller.py create mode 100644 imagescanner.py create mode 100644 imageviewer.py create mode 100644 metadatamanager.py create mode 100644 propertiesdialog.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 settings.py create mode 100644 setup.py create mode 100644 utils.py create mode 100644 widgets.py create mode 100644 xmpmanager.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..95a5004 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# BagheeraView + +BagheeraView is an image viewer specifically designed for the KDE ecosystem. Built with **Python** and **PySide6**, it leverages Baloo to deliver a powerful, agile, and fluid metadata-based image management experience without abandoning classic folder management. + +## 🚀 Key Features + +- **Enhanced Baloo Search:** Blazing fast image retrieval using the KDE Baloo indexing framework, featuring advanced capabilities not natively available in Baloo, such as **folder recursive search** and results **text exclusion**. + +- **Versatile Thumbnail Grid:** A fluid and responsive browser for large collections, offering both **Flat View, Date View** and **Folder View** modes. + +- **Face Detection:** Integrated computer vision to detect faces within your photos and assign person names. + +- **Metadata:** A basic viewer for **EXIF, IPTC, and XMP** data. + +- **Tagging & Rating & Comments System:** Effortlessly manage tags, ratings and comments that integrate directly with your filesystem's extended attributes. + +- **Smart State Persistence:** The application remembers your workflow. Your **last used sort order** and view settings are automatically saved and restored upon startup. + +## 🛠 Technical Stack + +- **Language:** Python 3 + +- **GUI Framework:** PySide6 (Qt for Python) + +- **KDE Integration:** Baloo search and management + +- **Metadata Handling:** Advanced image header manipulation to store faces and support to file extended attributes + + +## 🌐 Internationalization (i18n) + +BagheeraView is designed for a global audience with localized interface support. Initial supported languages include: + +- **English** (Base development language) + +- **Galician** + +- **Spanish** + +> **Note:** Following internal configuration standards, all source code labels and developer logs are maintained in English for technical consistency. + +## ⚙️ Configuration & Persistence + +BagheeraView is built for workflow continuity. The application stores the user's environment state in the local configuration: + +- **Restore Last Layout:** Last layout and preferences are automatically saved and restored every time you launch it. + +- **Keyboard configuration:** All hotkeys can be parametriced by the user. + +- **Interface Language:** The application automatically detects the system locale and applies the corresponding translation on startup or user can decide main language. + + +## 📥 Installation (Development) + +Ensure you have the necessary PySide6 dependencies and KDE development headers for Baloo installed on your system. + +Bash + +``` +# Clone the repository +git clone https://github.com/youruser/BagheeraView.git +cd BagheeraView + +# Install dependencies +pip install -r requirements.txt + +# Run the application +python main.py +``` + +## 🤝 Contributing + +We follow an **English-first policy** for the codebase and documentation. + +1. **Fork** the project. + +2. Create your **Feature Branch** (`git checkout -b feature/AmazingFeature`). + +3. **Commit** your changes (`git commit -m 'Add some AmazingFeature'`). + +4. **Push** to the branch (`git push origin feature/AmazingFeature`). + +5. Open a **Pull Request**. diff --git a/bagheera_query_parser_lib b/bagheera_query_parser_lib new file mode 120000 index 0000000..15dcf66 --- /dev/null +++ b/bagheera_query_parser_lib @@ -0,0 +1 @@ +/home/ignacio/devel/bagheera/bagheerasearch/bagheera_query_parser_lib \ No newline at end of file diff --git a/bagheera_search_lib b/bagheera_search_lib new file mode 120000 index 0000000..1d3741c --- /dev/null +++ b/bagheera_search_lib @@ -0,0 +1 @@ +/home/ignacio/devel/bagheera/bagheerasearch/bagheera_search_lib \ No newline at end of file diff --git a/bagheeraview b/bagheeraview new file mode 120000 index 0000000..19706e8 --- /dev/null +++ b/bagheeraview @@ -0,0 +1 @@ +/home/ignacio/.config/iserantes/bagheeraview \ No newline at end of file diff --git a/bagheeraview.desktop b/bagheeraview.desktop new file mode 100755 index 0000000..f806dae --- /dev/null +++ b/bagheeraview.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +Categories=Graphics;RasterGraphics;Viewer; +Comment[en_US]=Bagheera Image Viewer +Comment=Bagheera Image Viewer +Encoding=UTF-8 +Exec=bagheeraview %u +GenericName[en_US]=Bagheera Image Viewer +GenericName=Bagheera Image Viewer +Icon=bagheeraview +MimeType=inode/directory;image/x-xbitmap;image/x-tga;image/x-portable-pixmap;image/x-portable-graymap;image/x-portable-bitmap;image/x-pict;image/webp;image/vnd.zbrush.pcx;image/vnd.adobe.photoshop;image/tiff;image/png;image/jpeg;image/gif;image/bmp; +Name[en_US]=Bagheera Image Viewer +Name=Bagheera Image Viewer +NoDisplay=false +Path= +StartupNotify=true +Terminal=false +TerminalOptions= +Type=Application +X-DBUS-ServiceName= +X-DBUS-StartupType= +X-DCOP-ServiceType= +X-KDE-SubstituteUID=false +X-KDE-Username= diff --git a/bagheeraview.py b/bagheeraview.py new file mode 100755 index 0000000..3c59efa --- /dev/null +++ b/bagheeraview.py @@ -0,0 +1,4313 @@ +#!/usr/bin/env python3 +""" +Bagheera Image Viewer - Main Application. + +This is the main entry point for the Bagheera Image Viewer application. It +initializes the main window, handles application-wide shortcuts, manages the +thumbnail grid, and coordinates background scanning, caching, and image viewing. + +The application uses a model-view-delegate pattern for the thumbnail grid to +efficiently handle very large collections of images. + +Classes: + AppShortcutController: Global event filter for keyboard shortcuts. + MainWindow: The main application window containing the thumbnail grid and docks. +""" +__appname__ = "BagheeraView" +__version__ = "0.9.11" +__author__ = "Ignacio Serantes" +__email__ = "kde@aynoa.net" +__license__ = "LGPL" +__status__ = "Beta" +# "Prototype, Development, Alpha, Beta, Production, Stable, Deprecated" + +import sys +import os +import subprocess +import json +import glob +import shutil +from datetime import datetime +from collections import deque +from itertools import groupby + +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit, + QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy, + QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, + QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup, QListView, + QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox +) +from PySide6.QtGui import ( + QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette, + QStandardItemModel, QStandardItem, QColor, QPixmap, QPixmapCache, QPainter, + QKeySequence, QAction, QActionGroup +) +from PySide6.QtCore import ( + Qt, QPoint, QUrl, QObject, QEvent, QTimer, QMimeData, QByteArray, + QItemSelection, QSortFilterProxyModel, QItemSelectionModel, QRect, QSize, + QThread, QPersistentModelIndex, QModelIndex +) +from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus + +from pathlib import Path + +from constants import ( + APP_CONFIG, CONFIG_PATH, CURRENT_LANGUAGE, DEFAULT_GLOBAL_SHORTCUTS, + DEFAULT_VIEWER_SHORTCUTS, GLOBAL_ACTIONS, HISTORY_PATH, ICON_THEME, + ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, PROG_AUTHOR, + PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES, + 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, + 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 +) +import constants +from settings import SettingsDialog +from imagescanner import CacheCleaner, ImageScanner, ThumbnailCache, ThumbnailGenerator +from imageviewer import ImageViewer +from propertiesdialog import PropertiesDialog +from widgets import ( + CircularProgressBar, + TagEditWidget, LayoutsWidget, HistoryWidget, RatingWidget, CommentWidget +) +from metadatamanager import XattrManager + + +class ShortcutHelpDialog(QDialog): + """A dialog to display, filter, and edit keyboard shortcuts.""" + def __init__(self, global_shortcuts, viewer_shortcuts, main_win): + super().__init__(main_win) + self.global_shortcuts = global_shortcuts + self.viewer_shortcuts = viewer_shortcuts + self.main_win = main_win + + self.setWindowTitle(UITexts.SHORTCUTS_TITLE) + self.resize(500, 450) + + layout = QVBoxLayout(self) + + # Search bar + self.search_bar = QLineEdit() + self.search_bar.setPlaceholderText(UITexts.SHORTCUT_SEARCH_PLACEHOLDER) + self.search_bar.textChanged.connect(self.filter_table) + layout.addWidget(self.search_bar) + + # Table + self.table = QTableWidget() + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels([UITexts.SHORTCUTS_ACTION, + UITexts.SHORTCUTS_KEY]) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, + QHeaderView.ResizeToContents) + self.table.verticalHeader().setVisible(False) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.doubleClicked.connect(self.edit_shortcut) + layout.addWidget(self.table) + + self.populate_table() + + # Close button + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton(UITexts.CLOSE) + close_btn.clicked.connect(self.accept) + btn_layout.addWidget(close_btn) + layout.addLayout(btn_layout) + + def populate_table(self): + """Fills the table with the current shortcuts.""" + self.table.setRowCount(0) + shortcuts_list = [] + + def get_int_modifiers(mods): + try: + return int(mods) + except TypeError: + return mods.value + + # Global Shortcuts + for (key, mods), val in self.global_shortcuts.items(): + # val is (func, ignore, desc, category) + desc = val[2] + category = val[3] if len(val) > 3 else "Global" + seq = QKeySequence(get_int_modifiers(mods) | key) + shortcut_str = seq.toString(QKeySequence.NativeText) + shortcuts_list.append({'cat': category, 'desc': desc, 'sc': shortcut_str, + 'key': (key, mods), 'src': self.global_shortcuts}) + + # Viewer Shortcuts + for (key, mods), (action, desc) in self.viewer_shortcuts.items(): + seq = QKeySequence(get_int_modifiers(mods) | key) + shortcut_str = seq.toString(QKeySequence.NativeText) + shortcuts_list.append({'cat': "Viewer", 'desc': desc, 'sc': shortcut_str, + 'key': (key, mods), + 'src': self.viewer_shortcuts}) + + # Sort by Category then Description + shortcuts_list.sort(key=lambda x: (x['cat'], x['desc'])) + + current_cat = None + for item in shortcuts_list: + if item['cat'] != current_cat: + current_cat = item['cat'] + # Add header row + row = self.table.rowCount() + self.table.insertRow(row) + header_item = QTableWidgetItem(current_cat) + header_item.setFlags(Qt.ItemIsEnabled) + header_item.setBackground(QColor(60, 60, 60)) + header_item.setForeground(Qt.white) + font = header_item.font() + font.setBold(True) + header_item.setFont(font) + header_item.setData(Qt.UserRole, "header") + self.table.setItem(row, 0, header_item) + self.table.setSpan(row, 0, 1, 2) + + row = self.table.rowCount() + self.table.insertRow(row) + + item_desc = QTableWidgetItem(item['desc']) + item_desc.setData(Qt.UserRole, (item['key'], item['src'])) + item_sc = QTableWidgetItem(item['sc']) + item_sc.setData(Qt.UserRole, (item['key'], item['src'])) + + self.table.setItem(row, 0, item_desc) + self.table.setItem(row, 1, item_sc) + + def filter_table(self, text): + """Hides or shows table rows based on the search text.""" + text = text.lower() + current_header_row = -1 + category_has_visible_items = False + + for row in range(self.table.rowCount()): + action_item = self.table.item(row, 0) + if not action_item: + continue + + if action_item.data(Qt.UserRole) == "header": + # Process previous header visibility + if current_header_row != -1: + self.table.setRowHidden(current_header_row, + not category_has_visible_items) + + current_header_row = row + category_has_visible_items = False + self.table.setRowHidden(row, False) # Show tentatively + else: + shortcut_item = self.table.item(row, 1) + if action_item and shortcut_item: + action_text = action_item.text().lower() + shortcut_text = shortcut_item.text().lower() + match = text in action_text or text in shortcut_text + self.table.setRowHidden(row, not match) + if match: + category_has_visible_items = True + + # Handle last header + if current_header_row != -1: + self.table.setRowHidden(current_header_row, + not category_has_visible_items) + + def edit_shortcut(self, index): + """Handles the double-click event to allow shortcut customization.""" + if not index.isValid(): + return + + row = index.row() + data = self.table.item(row, 0).data(Qt.UserRole) + if not data or data == "header": + return + original_key_combo, source_dict = data + + current_sc_str = self.table.item(row, 1).text() + current_sequence = QKeySequence.fromString( + current_sc_str, QKeySequence.NativeText) + + dialog = QDialog(self) + dialog.setWindowTitle(UITexts.SHORTCUT_EDIT_TITLE) + layout = QVBoxLayout(dialog) + layout.addWidget( + QLabel(UITexts.SHORTCUT_EDIT_LABEL.format(self.table.item(row, 0).text()))) + key_edit = QKeySequenceEdit(current_sequence) + layout.addWidget(key_edit) + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + layout.addWidget(button_box) + + if dialog.exec() == QDialog.Accepted: + new_sequence = key_edit.keySequence() + if new_sequence.isEmpty() or new_sequence.count() == 0: + return + + new_key_combo = new_sequence[0] + new_key = new_key_combo.key() + new_mods = new_key_combo.keyboardModifiers() + new_key_tuple = (int(new_key), new_mods) + + # Check for conflicts in the same scope + if new_key_tuple in source_dict and new_key_tuple != original_key_combo: + # Handle different value structures + val = source_dict[new_key_tuple] + # Global: (action, ignore, desc, category), Viewer: (action, desc) + if len(val) == 4: + conflict_desc = val[2] + else: + conflict_desc = val[1] + + QMessageBox.warning(self, UITexts.SHORTCUT_CONFLICT_TITLE, + UITexts.SHORTCUT_CONFLICT_TEXT.format( + new_sequence.toString(QKeySequence.NativeText), + conflict_desc)) + return + + shortcut_data = source_dict.pop(original_key_combo) + source_dict[new_key_tuple] = shortcut_data + + self.table.item(row, 1).setText( + new_sequence.toString(QKeySequence.NativeText)) + new_data = (new_key_tuple, source_dict) + self.table.item(row, 0).setData(Qt.UserRole, new_data) + self.table.item(row, 1).setData(Qt.UserRole, new_data) + + +class AppShortcutController(QObject): + """ + Global event filter for application-wide keyboard shortcuts. + + This class is installed on the QApplication instance to intercept key press + events before they reach their target widgets. This allows for defining + global shortcuts that work regardless of which widget has focus, unless + the user is typing in an input field. + """ + def __init__(self, main_win): + """Initializes the shortcut controller. + + Args: + main_win (MainWindow): A reference to the main application window. + """ + super().__init__() + self.main_win = main_win + self._actions = self._get_actions() + self._shortcuts = {} + self.action_to_shortcut = {} + self._register_shortcuts() + + # Overwrite with loaded config if available + if hasattr(self.main_win, 'loaded_global_shortcuts') \ + and self.main_win.loaded_global_shortcuts: + loaded_list = self.main_win.loaded_global_shortcuts + self._shortcuts.clear() + self.action_to_shortcut.clear() + for key_combo, val_list in loaded_list: + # Expecting [act, ignore, desc, cat] + if len(val_list) == 4: + k, m = key_combo + act, ignore, desc, cat = val_list + key_tuple = (k, Qt.KeyboardModifiers(m)) + self._shortcuts[key_tuple] = (act, ignore, desc, cat) + self.action_to_shortcut[act] = key_tuple + + def _get_actions(self): + """Returns a dictionary mapping action strings to callable functions.""" + return { + "quit_app": self._quit_app, + "toggle_visibility": self._toggle_visibility, + "close_all_viewers": self._close_viewers, + "load_more_images": self.main_win.load_more_images, + "load_all_images": self.main_win.load_all_images, + "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), + "delete_permanently": + lambda: self.main_win.delete_current_image(permanent=True), + "rename_image": self._rename_image, + "refresh_content": self.main_win.refresh_content, + "first_image": lambda: self._handle_home_end(Qt.Key_Home), + "last_image": lambda: self._handle_home_end(Qt.Key_End), + "prev_page": lambda: self._handle_page_nav(Qt.Key_PageUp), + "next_page": lambda: self._handle_page_nav(Qt.Key_PageDown), + "zoom_in": lambda: self._handle_zoom(Qt.Key_Plus), + "toggle_faces": self._toggle_faces, + "zoom_out": lambda: self._handle_zoom(Qt.Key_Minus), + "select_all": self.main_win.select_all_thumbnails, + "select_none": self.main_win.select_none_thumbnails, + "invert_selection": self.main_win.invert_selection_thumbnails, + } + + def _register_shortcuts(self): + """Registers all application shortcuts from constants.""" + self.action_to_shortcut.clear() + for action, (key, mods, ignore) in DEFAULT_GLOBAL_SHORTCUTS.items(): + if action in GLOBAL_ACTIONS: + desc, category = GLOBAL_ACTIONS[action] + key_combo = (int(key), Qt.KeyboardModifiers(mods)) + self._shortcuts[key_combo] = (action, ignore, desc, category) + self.action_to_shortcut[action] = key_combo + + def eventFilter(self, obj, event): + """Filters events to handle global key presses.""" + if event.type() != QEvent.KeyPress: + return False + + key = event.key() + mods = event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier | + Qt.AltModifier | Qt.MetaModifier) + + # Special case: Ignore specific navigation keys when typing + focus_widget = QApplication.focusWidget() + is_typing = isinstance(focus_widget, (QComboBox, QLineEdit, QTextEdit, + QInputDialog)) + # if is_typing and key in (Qt.Key_Home, Qt.Key_End, Qt.Key_Delete, + # Qt.Key_Left, Qt.Key_Right, Qt.Key_Backspace): + if is_typing: + return False + + # Check if we have a handler for this combination + if (key, mods) in self._shortcuts: + action_name, ignore_if_typing, _, _ = self._shortcuts[(key, mods)] + + if ignore_if_typing: + focus_widget = QApplication.focusWidget() + if isinstance(focus_widget, (QComboBox, QLineEdit, QTextEdit, + QInputDialog)): + return False + + if action_name in self._actions: + self._actions[action_name]() + return True + + return False + + def show_help(self): + """Displays a dialog listing all registered shortcuts.""" + dialog = ShortcutHelpDialog(self._shortcuts, self.main_win.viewer_shortcuts, + self.main_win) + dialog.exec() + self.main_win.refresh_shortcuts() + + # --- Action Handlers --- + + def _quit_app(self): + self.main_win.perform_shutdown() + QApplication.quit() + + def _toggle_visibility(self): + self.main_win.toggle_visibility() + + def _close_viewers(self): + if not self.main_win.isVisible(): + self.main_win.toggle_visibility() + self.main_win.close_all_viewers() + + def _rename_image(self): + active_viewer = next((w for w in QApplication.topLevelWidgets() + if isinstance(w, ImageViewer) + and w.isActiveWindow()), None) + if active_viewer: + active_viewer.rename_current_image() + elif self.main_win.thumbnail_view.selectedIndexes(): + self.main_win.rename_image( + self.main_win.thumbnail_view.selectedIndexes()[0].row()) + + def _handle_home_end(self, key): + active_viewer = next((w for w in QApplication.topLevelWidgets() + if isinstance(w, ImageViewer) + and w.isActiveWindow()), None) + if active_viewer: + if key == Qt.Key_End: + active_viewer.controller.last() + else: + active_viewer.controller.first() + active_viewer.load_and_fit_image() + elif self.main_win.proxy_model.rowCount() > 0: + if key == Qt.Key_End \ + and self.main_win._scanner_last_index < \ + self.main_win._scanner_total_files: + self.main_win.scanner.load_images( + self.main_win._scanner_last_index, + self.main_win._scanner_total_files - + self.main_win._scanner_last_index) + + # Find the first/last actual thumbnail, skipping headers + model = self.main_win.proxy_model + count = model.rowCount() + target_row = -1 + + if key == Qt.Key_Home: + for row in range(count): + idx = model.index(row, 0) + if model.data(idx, ITEM_TYPE_ROLE) == 'thumbnail': + target_row = row + break + else: # End + for row in range(count - 1, -1, -1): + idx = model.index(row, 0) + if model.data(idx, ITEM_TYPE_ROLE) == 'thumbnail': + target_row = row + break + + if target_row >= 0: + target_idx = model.index(target_row, 0) + self.main_win.set_selection(target_idx) + + def _handle_page_nav(self, key): + active_viewer = next((w for w in QApplication.topLevelWidgets() + if isinstance(w, ImageViewer) + and w.isActiveWindow()), None) + if active_viewer: + if key == Qt.Key_PageDown: + active_viewer.next_image() + else: + active_viewer.prev_image() + elif self.main_win.isVisible(): + self.main_win.handle_page_nav(key) + + def _toggle_faces(self): + if self.main_win.isVisible(): + self.main_win.toggle_faces() + + def _handle_zoom(self, key): + active_viewer = next((w for w in QApplication.topLevelWidgets() + if isinstance(w, ImageViewer) + and w.isActiveWindow()), None) + if active_viewer: + if key == Qt.Key_Plus: + active_viewer.controller.zoom_factor *= 1.1 + active_viewer.update_view(True) + elif key == Qt.Key_Minus: + active_viewer.controller.zoom_factor *= 0.9 + active_viewer.update_view(True) + else: + if self.main_win.isVisible() \ + and not any(isinstance(w, ImageViewer) + and w.isActiveWindow() for w in QApplication.topLevelWidgets()): + size = self.main_win.slider.value() + if key == Qt.Key_Plus: + size += 16 + else: + size -= 16 + self.main_win.slider.setValue(size) + + +# --- Data roles for the thumbnail model --- +PATH_ROLE = Qt.UserRole + 1 +MTIME_ROLE = Qt.UserRole + 2 +TAGS_ROLE = Qt.UserRole + 3 +RATING_ROLE = Qt.UserRole + 4 +ITEM_TYPE_ROLE = Qt.UserRole + 5 +DIR_ROLE = Qt.UserRole + 6 +INODE_ROLE = Qt.UserRole + 7 +DEVICE_ROLE = Qt.UserRole + 8 +IMAGE_DATA_ROLE = Qt.UserRole + 9 +GROUP_NAME_ROLE = Qt.UserRole + 10 + + +class ThumbnailDelegate(QStyledItemDelegate): + """Draws each thumbnail in the virtualized view. + + This delegate is responsible for painting each item in the QListView, + including the image, filename, rating, and tags. This is much more + performant than creating a separate widget for each thumbnail. + """ + HEADER_HEIGHT = 25 + + def __init__(self, parent=None): + super().__init__(parent) + self.main_win = parent + + def paint(self, painter, option, index): + painter.save() + painter.setRenderHint(QPainter.SmoothPixmapTransform) + + item_type = index.data(ITEM_TYPE_ROLE) + + if item_type == 'header': + self.paint_header(painter, option, index) + else: + self.paint_thumbnail(painter, option, index) + + painter.restore() + + def paint_header(self, painter, option, index): + """Draws a group header item.""" + folder_path = index.data(DIR_ROLE) + group_name = index.data(GROUP_NAME_ROLE) + + is_collapsed = group_name in self.main_win.proxy_model.collapsed_groups + + prefix = "▶ " if is_collapsed else "▼ " + folder_name = prefix + (folder_path if folder_path else UITexts.UNKNOWN) + + # Background band + painter.fillRect(option.rect, option.palette.alternateBase()) + + # Separator line + sep_color = option.palette.text().color() + sep_color.setAlpha(80) + painter.setPen(sep_color) + line_y = option.rect.center().y() + painter.drawLine(option.rect.left(), line_y, option.rect.right(), line_y) + + # Folder name text with its own background to cover the line + font = painter.font() + font.setBold(True) + painter.setFont(font) + fm = painter.fontMetrics() + text_w = fm.horizontalAdvance(folder_name) + 20 + text_bg_rect = QRect(option.rect.center().x() - text_w // 2, + option.rect.top(), + text_w, + option.rect.height()) + painter.fillRect(text_bg_rect, option.palette.alternateBase()) + painter.setPen(option.palette.text().color()) + painter.drawText(option.rect, Qt.AlignCenter, folder_name) + + def paint_thumbnail(self, painter, option, index): + """Draws a thumbnail item with image, text, rating, and tags.""" + thumb_size = self.main_win.current_thumb_size + path = index.data(PATH_ROLE) + mtime = index.data(MTIME_ROLE) + inode = index.data(INODE_ROLE) + device_id = index.data(DEVICE_ROLE) + + # Optimization: Use QPixmapCache to avoid expensive QImage->QPixmap + # conversion on every paint event. + cache_key = f"thumb_{path}_{mtime}_{thumb_size}" + source_pixmap = QPixmapCache.find(cache_key) + + if not source_pixmap or source_pixmap.isNull(): + # Not in UI cache, try to get from main thumbnail cache (Memory/LMDB) + img, _ = self.main_win.cache.get_thumbnail( + path, requested_size=thumb_size, curr_mtime=mtime, + inode=inode, device_id=device_id, async_load=True) + + if img and not img.isNull(): + source_pixmap = QPixmap.fromImage(img) + QPixmapCache.insert(cache_key, source_pixmap) + else: + # Fallback: Check a separate cache key for the placeholder to avoid + # blocking the high-res update while still preventing repetitive + # conversions. + fallback_key = f"fb_{path}_{mtime}" + source_pixmap = QPixmapCache.find(fallback_key) + + if not source_pixmap or source_pixmap.isNull(): + # Fallback to IMAGE_DATA_ROLE (low res scan thumbnail) + img_fallback = index.data(IMAGE_DATA_ROLE) + if img_fallback and hasattr(img_fallback, 'isNull') \ + and not img_fallback.isNull(): + source_pixmap = QPixmap.fromImage(img_fallback) + QPixmapCache.insert(fallback_key, source_pixmap) + else: + # Fallback to the icon stored in the model + icon = index.data(Qt.DecorationRole) + if icon and not icon.isNull(): + source_pixmap = icon.pixmap(thumb_size, thumb_size) + # Icons are usually internally cached by Qt, minimal + # overhead + else: + # Empty fallback if nothing exists + source_pixmap = QPixmap() + + filename = index.data(Qt.DisplayRole) + tags = index.data(TAGS_ROLE) or [] + rating = index.data(RATING_ROLE) or 0 + + # --- Rectangles and Styles --- + full_rect = QRect(option.rect) + if option.state & QStyle.State_Selected: + painter.fillRect(full_rect, option.palette.highlight()) + pen_color = option.palette.highlightedText().color() + else: + pen_color = option.palette.text().color() + # --- Draw Components --- + # 1. Thumbnail Pixmap + img_bbox = QRect(full_rect.x(), full_rect.y() + 5, + full_rect.width(), thumb_size) + + # Calculate destination rect maintaining aspect ratio + pic_size = source_pixmap.size() + pic_size.scale(img_bbox.size(), Qt.KeepAspectRatio) + + pixmap_rect = QRect(QPoint(0, 0), pic_size) + pixmap_rect.moveCenter(img_bbox.center()) + painter.drawPixmap(pixmap_rect, source_pixmap) + + # Start drawing text below the thumbnail + text_y = full_rect.y() + thumb_size + 8 + + # 2. Filename + if APP_CONFIG.get("thumbnails_show_filename", True): + font = painter.font() # Get a copy + filename_font_size = APP_CONFIG.get("thumbnails_filename_font_size", + THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT) + font.setPointSize(filename_font_size) + painter.setFont(font) + + fm = painter.fontMetrics() + line_height = fm.height() + num_lines = APP_CONFIG.get("thumbnails_filename_lines", + THUMBNAILS_FILENAME_LINES_DEFAULT) + rect_height = line_height * num_lines + + text_rect = QRect(full_rect.x() + 4, text_y, + full_rect.width() - 8, rect_height) + + if option.state & QStyle.State_Selected: + painter.setPen(pen_color) + else: + filename_color_str = APP_CONFIG.get("thumbnails_filename_color", + THUMBNAILS_FILENAME_COLOR_DEFAULT) + painter.setPen(QColor(filename_color_str)) + + # Elide text to fit approximately in the given number of lines, then wrap. + elided_text = fm.elidedText(filename.replace('\n', ' '), Qt.ElideRight, + text_rect.width() * num_lines) + + flags = Qt.AlignCenter | Qt.TextWordWrap + painter.drawText(text_rect, flags, elided_text) + text_y += rect_height + + # 3. Rating (stars) + if APP_CONFIG.get("thumbnails_show_rating", True): + font = option.font # Reset font to avoid compounding size changes + font.setBold(False) + # Keep rating size relative but consistent + font.setPointSize(font.pointSize() - 1) + painter.setFont(font) + num_stars = (rating + 1) // 2 + stars_text = '★' * num_stars + '☆' * (5 - num_stars) + rating_rect = QRect(full_rect.x(), text_y, + full_rect.width(), 15) + + rating_color_str = APP_CONFIG.get("thumbnails_rating_color", + THUMBNAILS_RATING_COLOR_DEFAULT) + painter.setPen(QColor(rating_color_str)) + painter.drawText(rating_rect, Qt.AlignCenter, stars_text) + text_y += 15 + + # 4. Tags + if APP_CONFIG.get("thumbnails_show_tags", True): + font = painter.font() # Reset font again + tags_font_size = APP_CONFIG.get("thumbnails_tags_font_size", + THUMBNAILS_TAGS_FONT_SIZE_DEFAULT) + font.setPointSize(tags_font_size) + painter.setFont(font) + + fm = painter.fontMetrics() + line_height = fm.height() + num_lines = APP_CONFIG.get("thumbnails_tags_lines", + THUMBNAILS_TAGS_LINES_DEFAULT) + rect_height = line_height * num_lines + + if option.state & QStyle.State_Selected: + painter.setPen(pen_color) + else: + tags_color_str = APP_CONFIG.get("thumbnails_tags_color", + THUMBNAILS_TAGS_COLOR_DEFAULT) + painter.setPen(QColor(tags_color_str)) + + display_tags = [t.split('/')[-1] for t in tags] + tags_text = ", ".join(display_tags) + tags_rect = QRect(full_rect.x() + 4, text_y, + full_rect.width() - 8, rect_height) + elided_tags = fm.elidedText(tags_text, Qt.ElideRight, + tags_rect.width() * num_lines) + painter.drawText(tags_rect, Qt.AlignCenter | Qt.TextWordWrap, elided_tags) + + def sizeHint(self, option, index): + """Provides the size hint for each item, including all elements.""" + # Check for the special 'header' type only if we have a valid index + if index and index.isValid(): + item_type = index.data(ITEM_TYPE_ROLE) + if item_type == 'header': + # To ensure the header item occupies a full row in the flow layout + # of the IconMode view, we set its width to the viewport's width + # minus a small margin. This prevents other items from trying to + # flow next to it. + return QSize(self.main_win.thumbnail_view.viewport().width() - 5, + self.HEADER_HEIGHT) + + # Default size for a standard thumbnail item (or when index is None) + thumb_size = self.main_win.current_thumb_size + # Height: thumb + top padding + height = thumb_size + 8 + + # Use a temporary font to get font metrics for accurate height calculation + font = QFont(self.main_win.font()) + + if APP_CONFIG.get("thumbnails_show_filename", True): + font.setPointSize(APP_CONFIG.get( + "thumbnails_filename_font_size", THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT)) + fm = QFontMetrics(font) + num_lines = APP_CONFIG.get( + "thumbnails_filename_lines", THUMBNAILS_FILENAME_LINES_DEFAULT) + height += fm.height() * num_lines + + if APP_CONFIG.get("thumbnails_show_rating", True): + height += 15 # rating rect height + + if APP_CONFIG.get("thumbnails_show_tags", True): + font.setPointSize(APP_CONFIG.get( + "thumbnails_tags_font_size", THUMBNAILS_TAGS_FONT_SIZE_DEFAULT)) + fm = QFontMetrics(font) + num_lines = APP_CONFIG.get( + "thumbnails_tags_lines", THUMBNAILS_TAGS_LINES_DEFAULT) + height += fm.height() * num_lines + + height += 5 # bottom padding + width = thumb_size + 10 + return QSize(width, height) + + +class ThumbnailSortFilterProxyModel(QSortFilterProxyModel): + """Proxy model to manage filtering and sorting of thumbnails. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.main_win = parent + self._data_cache = {} + self.include_tags = set() + self.exclude_tags = set() + self.name_filter = "" + self.match_mode = "AND" + self.group_by_folder = False + self.group_by_day = False + self.group_by_week = False + self.group_by_month = False + self.group_by_year = False + self.group_by_rating = False + self.collapsed_groups = set() + + def prepare_filter(self): + """Builds a cache of paths to tags and names for faster filtering.""" + if self.main_win: + # found_items_data: list of (path, qi, mtime, tags, rating, inode, dev) + # We pre-calculate sets and lowercase names for O(1) access + self._data_cache = { + item[0]: (set(item[3]) if item[3] else set(), + os.path.basename(item[0]).lower()) + for item in self.main_win.found_items_data + } + else: + self._data_cache = {} + + def clear_cache(self): + """Clears the internal filter data cache.""" + self._data_cache = {} + + def add_to_cache(self, path, tags): + """Adds a single item to the filter cache incrementally.""" + self._data_cache[path] = (set(tags) if tags else set(), + os.path.basename(path).lower()) + + def filterAcceptsRow(self, source_row, source_parent): + """Determines if a row should be visible based on current filters.""" + index = self.sourceModel().index(source_row, 0, source_parent) + path = index.data(PATH_ROLE) + + if not path: + item_type = index.data(ITEM_TYPE_ROLE) + if item_type == 'header': + return (self.group_by_folder or self.group_by_day or + self.group_by_week or self.group_by_month or + self.group_by_year or self.group_by_rating) + return False + + # Use cached data if available, otherwise fallback to model data + tags, name_lower = self._data_cache.get( + path, (set(index.data(TAGS_ROLE) or []), os.path.basename(path).lower())) + + # Filter collapsed groups + if self.main_win and (self.group_by_folder or self.group_by_day or + self.group_by_week or self.group_by_month or + self.group_by_year or self.group_by_rating): + mtime = index.data(MTIME_ROLE) + rating = index.data(RATING_ROLE) + _, group_name = self.main_win._get_group_info(path, mtime, rating) + if group_name in self.collapsed_groups: + return False + + # Filter by filename + if self.name_filter and self.name_filter not in name_lower: + return False + + # Filter by tags + show = False + if not self.include_tags: + show = True + elif self.match_mode == "AND": + show = self.include_tags.issubset(tags) + else: # OR mode + show = not self.include_tags.isdisjoint(tags) + + # Apply exclusion filter + if show and self.exclude_tags: + if not self.exclude_tags.isdisjoint(tags): + show = False + + return show + + def lessThan(self, left, right): + """Custom sorting logic for name and date.""" + sort_role = self.sortRole() + left_data = self.sourceModel().data(left, sort_role) + right_data = self.sourceModel().data(right, sort_role) + + if sort_role == MTIME_ROLE: + left = left_data if left_data is not None else 0 + right = right_data if right_data is not None else 0 + return left < right + + # Default (DisplayRole) is case-insensitive name sorting + # Handle None values safely + l_str = str(left_data) if left_data is not None else "" + r_str = str(right_data) if right_data is not None else "" + + return l_str.lower() < r_str.lower() + + +class MainWindow(QMainWindow): + """ + The main application window, which serves as the central hub for browsing + and managing images. + + 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): + """ + Initializes the MainWindow. + + Args: + cache (ThumbnailCache): The shared thumbnail cache instance. + args (list): Command-line arguments passed to the application. + """ + super().__init__() + self.cache = cache + self.setWindowTitle(f"{PROG_NAME} v{PROG_VERSION}") + self.set_app_icon() + + self.viewer_shortcuts = {} + self.full_history = [] + self.history = [] + self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE + self.face_names_history = [] + self.pet_names_history = [] + self.object_names_history = [] + self.landmark_names_history = [] + self.mru_tags = deque(maxlen=APP_CONFIG.get( + "tags_menu_max_items", TAGS_MENU_MAX_ITEMS_DEFAULT)) + self.scanner = None + self.thumbnail_generator = None + self.show_viewer_status_bar = True + self.show_filmstrip = False + self.filmstrip_position = 'bottom' # bottom, left, top, right + self.show_faces = False + self.is_cleaning = False + self._scan_all = False + self._suppress_updates = False + self._is_loading_all = False + + self._high_res_mode_active = False + self._is_loading = False + self._scanner_last_index = 0 + self._scanner_total_files = 0 + self._current_thumb_tier = 0 + self._open_with_cache = {} # Cache for mime_type -> list of app info + self._app_info_cache = {} # Cache for desktop_file_id + self._group_info_cache = {} + self._visible_paths_cache = None # Cache for visible image paths + self._path_to_model_index = {} + + # Keep references to open viewers to manage their lifecycle + self.viewers = [] + + # --- UI Setup --- + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + + self.loaded_global_shortcuts = None + # Top bar with search and actions + top = QHBoxLayout() + self.search_input = QComboBox() + self.search_input.setEditable(True) + self.search_input.lineEdit().returnPressed.connect(self.on_search_triggered) + self.search_input.lineEdit().setClearButtonEnabled(True) + self.search_input.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + top.addWidget(self.search_input, 1) # Make search input expandable + + for t, f in [(UITexts.SEARCH, + self.on_search_triggered), + (UITexts.SELECT, self.select_directory)]: + btn = QPushButton(t) + btn.clicked.connect(f) + btn.setFocusPolicy(Qt.NoFocus) + top.addWidget(btn) + + self.menu_btn = QPushButton() + self.menu_btn.setIcon(QIcon.fromTheme("application-menu")) + self.menu_btn.setFocusPolicy(Qt.NoFocus) + self.menu_btn.clicked.connect(self.show_main_menu) + self.menu_btn.setFixedHeight(self.search_input.height()) + top.addWidget(self.menu_btn) + layout.addLayout(top) + + # --- Central Area (Virtualized Thumbnail View) --- + self.thumbnail_view = QListView() + self.thumbnail_view.setViewMode(QListView.IconMode) + self.thumbnail_view.setResizeMode(QListView.Adjust) + self.thumbnail_view.setMovement(QListView.Static) + self.thumbnail_view.setUniformItemSizes(True) + self.thumbnail_view.setSpacing(5) + self.thumbnail_view.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.thumbnail_view.setContextMenuPolicy(Qt.CustomContextMenu) + bg_color = APP_CONFIG.get("thumbnails_bg_color", THUMBNAILS_BG_COLOR_DEFAULT) + self.thumbnail_view.setStyleSheet(f"background-color: {bg_color};") + self.thumbnail_view.customContextMenuRequested.connect(self.show_context_menu) + self.thumbnail_view.doubleClicked.connect(self.on_view_double_clicked) + + self.thumbnail_model = QStandardItemModel(self) + self.proxy_model = ThumbnailSortFilterProxyModel(self) + self.proxy_model.setSourceModel(self.thumbnail_model) + self.proxy_model.setDynamicSortFilter(False) # Manual invalidation + + self.thumbnail_view.setModel(self.proxy_model) + self.thumbnail_view.selectionModel().selectionChanged.connect( + self.on_selection_changed) + + self.delegate = ThumbnailDelegate(self) + self.thumbnail_view.setItemDelegate(self.delegate) + + layout.addWidget(self.thumbnail_view) + + # Bottom bar with status and controls + bot = QHBoxLayout() + self.status_lbl = QLabel(UITexts.READY) + bot.addWidget(self.status_lbl) + + self.progress_bar = CircularProgressBar(self) + self.progress_bar.hide() + bot.addWidget(self.progress_bar) + + # Timer to hide progress bar with delay + self.hide_progress_timer = QTimer(self) + self.hide_progress_timer.setSingleShot(True) + self.hide_progress_timer.timeout.connect(self.progress_bar.hide) + + self.btn_load_more = QPushButton("+") + self.btn_load_more.setFixedSize(24, 24) + self.btn_load_more.setFocusPolicy(Qt.NoFocus) + self.btn_load_more.setToolTip(UITexts.LOAD_MORE_TOOLTIP) + self.btn_load_more.clicked.connect(self.load_more_images) + bot.addWidget(self.btn_load_more) + + self.btn_load_all = QPushButton("+a") + self.btn_load_all.setFixedSize(24, 24) + self.btn_load_all.setFocusPolicy(Qt.NoFocus) + self.btn_load_all.clicked.connect(self.load_all_images) + self.update_load_all_button_state() + bot.addWidget(self.btn_load_all) + + bot.addStretch() + + self.filtered_count_lbl = QLabel(UITexts.FILTERED_ZERO) + bot.addWidget(self.filtered_count_lbl) + + self.view_mode_combo = QComboBox() + self.view_mode_combo.addItems([ + UITexts.VIEW_MODE_FLAT, UITexts.VIEW_MODE_FOLDER, UITexts.VIEW_MODE_DAY, + UITexts.VIEW_MODE_WEEK, UITexts.VIEW_MODE_MONTH, UITexts.VIEW_MODE_YEAR, + UITexts.VIEW_MODE_RATING + ]) + self.view_mode_combo.setFocusPolicy(Qt.NoFocus) + self.view_mode_combo.currentIndexChanged.connect(self.on_view_mode_changed) + bot.addWidget(self.view_mode_combo) + + self.sort_combo = QComboBox() + self.sort_combo.addItems([UITexts.SORT_NAME_ASC, UITexts.SORT_NAME_DESC, + UITexts.SORT_DATE_ASC, UITexts.SORT_DATE_DESC]) + self.sort_combo.setFocusPolicy(Qt.NoFocus) + self.sort_combo.currentIndexChanged.connect(self.on_sort_changed) + bot.addWidget(self.sort_combo) + + self.slider = QSlider(Qt.Horizontal) + self.slider.setRange(64, 512) + self.slider.setSingleStep(8) + self.slider.setPageStep(8) + self.slider.setValue(THUMBNAILS_DEFAULT_SIZE) + self.slider.setMaximumWidth(100) + self.slider.setMinimumWidth(75) + self.slider.setFocusPolicy(Qt.NoFocus) + self.slider.valueChanged.connect(self.on_slider_changed) + bot.addWidget(self.slider) + + self.size_label = QLabel(f"{self.current_thumb_size}px") + self.size_label.setFixedWidth(50) + bot.addWidget(self.size_label) + + layout.addLayout(bot) + + # --- Main Dock --- + self.main_dock = QDockWidget(UITexts.MAIN_DOCK_TITLE, self) + self.main_dock.setObjectName("MainDock") + self.main_dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + + self.tags_tabs = QTabWidget() + + # Tab 1: Tags (Edit) + self.tag_edit_widget = TagEditWidget(self) + self.tag_edit_widget.tags_updated.connect(self.on_tags_edited) + + self.tags_tabs.addTab(self.tag_edit_widget, UITexts.TAGS_TAB) + self.tags_tabs.currentChanged.connect(self.on_tags_tab_changed) + + # Tab 2: Information (Rating, Comment) + self.info_widget = QWidget() + info_layout = QVBoxLayout(self.info_widget) + self.rating_widget = RatingWidget() + self.rating_widget.rating_updated.connect(self.on_rating_edited) + info_layout.addWidget(self.rating_widget) + self.comment_widget = CommentWidget() + info_layout.addWidget(self.comment_widget) + self.tags_tabs.addTab(self.info_widget, UITexts.INFO_TAB) + self.tags_tabs.currentChanged.connect(self.on_tags_tab_changed) + + # Timer for debouncing filter text input to prevent UI freezing + self.filter_input_timer = QTimer(self) + self.filter_input_timer.setSingleShot(True) + self.filter_input_timer.setInterval(300) + self.filter_input_timer.timeout.connect(self.apply_filters) + + # Timer for debouncing tag list updates in the filter tab for performance + self.filter_refresh_timer = QTimer(self) + self.filter_refresh_timer.setSingleShot(True) + self.filter_refresh_timer.setInterval(1500) + self.filter_refresh_timer.timeout.connect(self.update_tag_list) + + # Tab 3: Filter by Tags and Name + self.filter_widget = QWidget() + filter_layout = QVBoxLayout(self.filter_widget) + + self.filter_name_input = QLineEdit() + self.filter_name_input.setPlaceholderText(UITexts.FILTER_NAME_PLACEHOLDER) + # Use debounce timer instead of direct connection + self.filter_name_input.textChanged.connect(self.filter_input_timer.start) + self.filter_name_input.setClearButtonEnabled(True) + filter_layout.addWidget(self.filter_name_input) + + mode_layout = QHBoxLayout() + self.filter_mode_group = QButtonGroup(self) + rb_and = QRadioButton(UITexts.FILTER_AND) + rb_or = QRadioButton(UITexts.FILTER_OR) + rb_and.setChecked(True) + self.filter_mode_group.addButton(rb_and) + self.filter_mode_group.addButton(rb_or) + mode_layout.addWidget(rb_and) + mode_layout.addWidget(rb_or) + + btn_invert = QPushButton(UITexts.FILTER_INVERT) + btn_invert.setFixedWidth(60) + btn_invert.clicked.connect(self.invert_tag_selection) + mode_layout.addWidget(btn_invert) + filter_layout.addLayout(mode_layout) + self.filter_mode_group.buttonClicked.connect(self.apply_filters) + + self.filter_stats_lbl = QLabel() + self.filter_stats_lbl.setAlignment(Qt.AlignCenter) + self.filter_stats_lbl.setStyleSheet("color: gray; font-style: italic;") + self.filter_stats_lbl.hide() + filter_layout.addWidget(self.filter_stats_lbl) + + self.tag_search_input = QLineEdit() + self.tag_search_input.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER) + self.tag_search_input.textChanged.connect(self.filter_tags_list) + self.tag_search_input.setClearButtonEnabled(True) + filter_layout.addWidget(self.tag_search_input) + + self.tags_list = QTableWidget() + self.tags_list.setColumnCount(2) + self.tags_list.setHorizontalHeaderLabels( + [UITexts.FILTER_TAG_COLUMN, UITexts.FILTER_NOT_COLUMN]) + self.tags_list.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.tags_list.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed) + self.tags_list.setColumnWidth(1, 40) + self.tags_list.verticalHeader().setVisible(False) + self.tags_list.setSelectionMode(QAbstractItemView.NoSelection) + self.tags_list.itemChanged.connect(self.on_tag_changed) + filter_layout.addWidget(self.tags_list) + + self.tags_tabs.addTab(self.filter_widget, UITexts.TAG_FILTER_TAB) + + # Tab 4: Layouts + self.is_xcb = QApplication.platformName() == "xcb" + if self.is_xcb: + self.layouts_tab = LayoutsWidget(self) + self.tags_tabs.addTab(self.layouts_tab, UITexts.LAYOUTS_TAB) + + # Tab 5: History + self.history_tab = HistoryWidget(self) + self.tags_tabs.addTab(self.history_tab, UITexts.HISTORY_TAB) + + self.main_dock.setWidget(self.tags_tabs) + self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock) + + self.main_dock.hide() + + # Timer for debouncing UI refreshes to keep it smooth on resize + self.thumbnails_refresh_timer = QTimer(self) + self.thumbnails_refresh_timer.setSingleShot(True) + refresh_interval = APP_CONFIG.get("thumbnails_refresh_interval", + THUMBNAILS_REFRESH_INTERVAL_DEFAULT) + self.thumbnails_refresh_timer.setInterval(refresh_interval) + self.thumbnails_refresh_timer.timeout.connect( + self.thumbnail_view.updateGeometries) + + # Queue and timer for incremental model updates (prevents UI freeze + # on fast scan) + self._model_update_queue = deque() + self._model_update_timer = QTimer(self) + self._model_update_timer.setInterval(30) # ~30 FPS updates + self._model_update_timer.timeout.connect(self._process_model_update_queue) + + # Data collection and model rebuilding logic + self.found_items_data = [] + self._known_paths = set() + self.cache.thumbnail_loaded.connect(self.on_thumbnail_loaded) + self.rebuild_timer = QTimer(self) + self.rebuild_timer.setSingleShot(True) + self.rebuild_timer.setInterval(150) # Rebuild view periodically during scan + self.rebuild_timer.timeout.connect(self.rebuild_view) + + # Timer to resume scanning after user interaction stops + self.resume_scan_timer = QTimer(self) + self.resume_scan_timer.setSingleShot(True) + self.resume_scan_timer.setInterval(400) + self.resume_scan_timer.timeout.connect(self._resume_scanning) + + # # Timer for debouncing tag list updates in the filter tab for performance + # self.filter_refresh_timer = QTimer(self) + # self.filter_refresh_timer.setSingleShot(True) + # self.filter_refresh_timer.setInterval(1500) + # self.filter_refresh_timer.timeout.connect(self.update_tag_list) + + # Monitor viewport resize to recalculate item layout (for headers) + self.thumbnail_view.viewport().installEventFilter(self) + self.thumbnail_view.verticalScrollBar().valueChanged.connect( + self._on_scroll_interaction) + + # Initial configuration loading + self.load_config() + self.load_full_history() + + 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] + + if hasattr(self, 'history_tab'): + self.history_tab.refresh_list() + + # Handle initial arguments passed from the command line + should_hide = False + if args: + path = " ".join(args).strip() + # Fix `file:/` URLs from file managers + if path.startswith("file:/"): + path = path[6:] + full_path = os.path.abspath(os.path.expanduser(path)) + if os.path.isfile(full_path) or path.startswith("layout:/"): + # If a single file or a layout is passed, hide the main window + should_hide = True + + self.handle_initial_args(args) + elif self.history: + # If no args, load the last used path or search from history + last_term = self.history[0] + # Check if the last item was a single file to decide on visibility + if last_term.startswith("file:/") or last_term.startswith("/"): + p = last_term[6:] if last_term.startswith("file:/") else last_term + if os.path.isfile(os.path.abspath(os.path.expanduser(p))): + should_hide = True + self._scan_all = False + + self.process_term(last_term) + + if should_hide: + self.hide() + else: + self.show() + self.setFocus() + + def _process_model_update_queue(self): + """Processes a chunk of the pending model updates.""" + if not self._model_update_queue: + self._model_update_timer.stop() + return + + # Process a chunk of items (e.g. 100 items per tick) to maintain responsiveness + chunk = [] + try: + for _ in range(100): + chunk.append(self._model_update_queue.popleft()) + except IndexError: + pass + + if chunk: + self._incremental_add_to_model(chunk) + + # Stop if empty, otherwise it continues on next tick + if not self._model_update_queue: + self._model_update_timer.stop() + # Ensure filter stats are updated at the end + self.apply_filters() + + def _apply_global_stylesheet(self): + """Applies application-wide stylesheets from config.""" + tooltip_bg = APP_CONFIG.get("thumbnails_tooltip_bg_color", + THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT) + tooltip_fg = APP_CONFIG.get( + "thumbnails_tooltip_fg_color", THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT) + + # Using QPalette is often more robust for platform-themed widgets like tooltips, + # as it's more likely to be respected by the style engine (e.g., KDE Breeze) + # than a stylesheet alone. + palette = QApplication.palette() + palette.setColor(QPalette.ToolTipBase, QColor(tooltip_bg)) + palette.setColor(QPalette.ToolTipText, QColor(tooltip_fg)) + QApplication.setPalette(palette) + + qss = f""" + QToolTip {{ + background-color: {tooltip_bg}; + color: {tooltip_fg}; + border: 1px solid #555; + padding: 4px; + }} + """ + QApplication.instance().setStyleSheet(qss) + + def _on_scroll_interaction(self, value): + """Pauses scanning during scroll to keep UI fluid.""" + if self.scanner and self.scanner.isRunning(): + self.scanner.set_paused(True) + self.resume_scan_timer.start() + + def _resume_scanning(self): + """Resumes scanning after interaction pause.""" + if self.scanner: + # Prioritize currently visible images + visible_paths = self.get_visible_image_paths() + self.scanner.prioritize(visible_paths) + self.scanner.set_paused(False) + + # --- Layout Management --- + def save_layout(self, target_path=None): + """Saves the current window and viewer layout to a JSON file.""" + if not self.is_xcb: + return + + # Ensure the layouts directory exists + os.makedirs(LAYOUTS_DIR, exist_ok=True) + + filename = None + name = "" + + if target_path: + filename = target_path + name = os.path.basename(filename).replace(".layout", "") + + # Confirm overwrite if the file already exists + confirm = QMessageBox(self) + confirm.setIcon(QMessageBox.Warning) + confirm.setWindowTitle(UITexts.LAYOUT_EXISTS_TITLE) + confirm.setText(UITexts.LAYOUT_EXISTS_TEXT.format(name)) + confirm.setInformativeText(UITexts.LAYOUT_EXISTS_INFO) + confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + confirm.setDefaultButton(QMessageBox.No) + if confirm.exec() != QMessageBox.Yes: + return + else: + # Prompt for a new layout name + while True: + name, ok = QInputDialog.getText( + self, UITexts.SAVE_LAYOUT_TITLE, UITexts.SAVE_LAYOUT_TEXT) + if not ok or not name.strip(): + return + + filename = os.path.join(LAYOUTS_DIR, f"{name.strip()}.layout") + if os.path.exists(filename): + confirm = QMessageBox(self) + confirm.setIcon(QMessageBox.Warning) + confirm.setWindowTitle(UITexts.LAYOUT_EXISTS_TITLE) + confirm.setText(UITexts.LAYOUT_EXISTS_TEXT.format(name.strip())) + confirm.setInformativeText(UITexts.LAYOUT_EXISTS_INFO) + confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + confirm.setDefaultButton(QMessageBox.No) + if confirm.exec() == QMessageBox.Yes: + break + else: + break + + # Main window data to be saved + # layout_data = { + # "main_window": { + # "visible": self.isVisible(), + # "geometry": { + # "x": self.x(), "y": self.y(), + # "w": self.width(), "h": self.height() + # }, + # "search_text": self.search_input.currentText(), + # "selected_path": self.get_current_selected_path() + # }, + # "viewers": [] + # } + layout_data = { + "main_window": { + "visible": self.isVisible(), + "search_text": self.search_input.currentText(), + "geometry": { + "x": self.x(), "y": self.y(), + "w": self.width(), "h": self.height() + }, + "window_state": self.saveState().toBase64().data().decode(), + "selected_path": self.get_current_selected_path() + }, + "viewers": [] + } + + # Data from open viewers + # We filter to ensure the widget is still alive and visible + active_viewers = [v for v in self.viewers + if isinstance(v, ImageViewer) and v.isVisible()] + + for v in active_viewers: + layout_data["viewers"].append(v.get_state()) + + try: + with open(filename, 'w') as f: + json.dump(layout_data, f, indent=4) + self.status_lbl.setText(UITexts.LAYOUT_SAVED.format(name)) + if hasattr(self, 'layouts_tab'): + self.layouts_tab.refresh_list() + except Exception as e: + QMessageBox.critical( + self, UITexts.ERROR, UITexts.ERROR_SAVING_LAYOUT.format(e)) + + def load_layout_dialog(self): + """Shows a dialog to select and load a layout.""" + if not self.is_xcb: + return + + if not os.path.exists(LAYOUTS_DIR): + QMessageBox.information(self, UITexts.INFO, UITexts.NO_LAYOUTS_FOUND) + return + + files = glob.glob(os.path.join(LAYOUTS_DIR, "*.layout")) + if not files: + QMessageBox.information(self, UITexts.INFO, UITexts.NO_LAYOUTS_FOUND) + return + + # Get clean names without extension + items = [os.path.basename(f).replace(".layout", "") for f in files] + items.sort() + + item, ok = QInputDialog.getItem( + self, UITexts.LOAD_LAYOUT_TITLE, UITexts.SELECT_LAYOUT, items, 0, False) + if ok and item: + full_path = os.path.join(LAYOUTS_DIR, f"{item}.layout") + self.restore_layout(full_path) + + def close_all_viewers(self): + """Closes all currently open image viewer windows gracefully.""" + for v in list(self.viewers): + try: + v.close() + except Exception: + pass + self.viewers.clear() + + def restore_layout(self, filepath): + """Restores the complete application state from a layout file.""" + try: + with open(filepath, 'r') as f: + data = json.load(f) + except Exception as e: + QMessageBox.critical(self, UITexts.ERROR, + f"Failed to load layout file: {e}") + return + + # Ensure main window is visible before restoring + if not self.isVisible(): + self.show() + + # Clear any currently open viewers + self.close_all_viewers() + + # Restore main window state + mw_data = data.get("main_window", {}) + + # Restore main window geometry and state (including docks) + if "geometry" in mw_data: + g = mw_data["geometry"] + self.setGeometry(g["x"], g["y"], g["w"], g["h"]) + if "window_state" in mw_data: + self.restoreState( + QByteArray.fromBase64(mw_data["window_state"].encode())) + + # Restore viewers + viewers_data = data.get("viewers", []) + + # Gather all unique paths from the viewers to scan + paths = [] + for v_data in viewers_data: + path = v_data.get("path") + if path not in paths: + paths.append(path) + + # Set scan mode and search text + self._scan_all = True + search_text = mw_data.get("search_text", "") + + # Restore main window visibility + if mw_data.get("visible", True): + self.show() + self.activateWindow() + else: + self.hide() + + # Create and restore each viewer + # 4. Restore Viewers + for v_data in viewers_data: + path = v_data.get("path") + if os.path.exists(path): + # Create viewer with a temporary list containing only its image. + # The full list will be synced later after the scan completes. + viewer = ImageViewer(self.cache, [path], 0, initial_tags=None, + initial_rating=0, parent=self, + restore_config=v_data, persistent=True) + # Apply saved geometry + v_geo = v_data.get("geometry") + if v_geo: + viewer.setGeometry(v_geo["x"], v_geo["y"], v_geo["w"], v_geo["h"]) + + 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) + viewer.show() + + self.status_lbl.setText(UITexts.LAYOUT_RESTORED) + + # 5. Start scanning all parent directories of the images in the layout + unique_dirs = list({str(Path(p).parent) for p in paths}) + for d in unique_dirs: + paths.append(d) + self.start_scan([p.strip() for p in paths if p.strip() + and os.path.exists(os.path.expanduser(p.strip()))], + select_path=mw_data.get("selected_path")) + if search_text: + self.search_input.setEditText(search_text) + + # --- UI and Menu Logic --- + + def show_main_menu(self): + """Displays the main application menu.""" + menu = QMenu(self) + + # Actions to show different tabs in the dock + show_tags_action = menu.addAction(QIcon.fromTheme("document-properties"), + UITexts.MENU_SHOW_TAGS) + show_tags_action.triggered.connect(lambda: self.open_sidebar_tab(0)) + + show_info_action = menu.addAction(QIcon.fromTheme("dialog-information"), + UITexts.MENU_SHOW_INFO) + show_info_action.triggered.connect(lambda: self.open_sidebar_tab(1)) + + show_filter_action = menu.addAction(QIcon.fromTheme("view-filter"), + UITexts.MENU_SHOW_FILTER) + show_filter_action.triggered.connect(lambda: self.open_sidebar_tab(2)) + + if self.is_xcb: + show_layouts_action = menu.addAction(QIcon.fromTheme("view-grid"), + UITexts.MENU_SHOW_LAYOUTS) + l_idx = self.tags_tabs.indexOf(self.layouts_tab) + show_layouts_action.triggered.connect(lambda: self.open_sidebar_tab(l_idx)) + + show_history_action = menu.addAction(QIcon.fromTheme("view-history"), + UITexts.MENU_SHOW_HISTORY) + h_idx = self.tags_tabs.indexOf(self.history_tab) + show_history_action.triggered.connect(lambda: self.open_sidebar_tab(h_idx)) + + menu.addSeparator() + + # Cache management actions + count, size = self.cache.get_cache_stats() + size_mb = size / (1024 * 1024) + + disk_cache_size_mb = 0 + disk_cache_path = os.path.join(constants.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) + + cache_menu = menu.addMenu(QIcon.fromTheme("drive-harddisk"), UITexts.MENU_CACHE) + + clean_cache_action = cache_menu.addAction(QIcon.fromTheme("edit-clear-all"), + UITexts.MENU_CLEAN_CACHE) + clean_cache_action.triggered.connect(self.clean_thumbnail_cache) + + clear_cache_action = cache_menu.addAction( + QIcon.fromTheme("user-trash-full"), + UITexts.MENU_CLEAR_CACHE.format(count, size_mb, disk_cache_size_mb)) + clear_cache_action.triggered.connect(self.clear_thumbnail_cache) + + menu.addSeparator() + + show_shortcuts_action = menu.addAction(QIcon.fromTheme("help-keys"), + UITexts.MENU_SHOW_SHORTCUTS) + show_shortcuts_action.triggered.connect(self.show_shortcuts_help) + + menu.addSeparator() + + # --- Language Menu --- + language_menu = menu.addMenu(QIcon.fromTheme("preferences-desktop-locale"), + UITexts.MENU_LANGUAGE) + lang_group = QActionGroup(self) + lang_group.setExclusive(True) + lang_group.triggered.connect(self._on_language_changed) + + for code, name in SUPPORTED_LANGUAGES.items(): + action = QAction(name, self, checkable=True) + action.setData(code) + if code == CURRENT_LANGUAGE: + action.setChecked(True) + language_menu.addAction(action) + lang_group.addAction(action) + + menu.addSeparator() + + settings_action = menu.addAction(QIcon.fromTheme("preferences-system"), + UITexts.MENU_SETTINGS) + settings_action.triggered.connect(self.show_settings_dialog) + + menu.addSeparator() + + about_action = menu.addAction(QIcon.fromTheme("help-about"), UITexts.MENU_ABOUT) + about_action.triggered.connect(self.show_about_dialog) + + menu.exec(self.menu_btn.mapToGlobal(QPoint(0, self.menu_btn.height()))) + + def show_about_dialog(self): + """Shows the 'About' dialog box.""" + QMessageBox.about(self, UITexts.MENU_ABOUT_TITLE.format(PROG_NAME), + UITexts.MENU_ABOUT_TEXT.format( + PROG_NAME, PROG_VERSION, PROG_AUTHOR)) + + def show_shortcuts_help(self): + if hasattr(self, 'shortcut_controller') and self.shortcut_controller: + self.shortcut_controller.show_help() + + def show_settings_dialog(self): + dlg = SettingsDialog(self) + if dlg.exec(): + # Update settings that affect the main window immediately + new_interval = APP_CONFIG.get("thumbnails_refresh_interval", + constants.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) + 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) + if len(self.face_names_history) > new_max_faces: + self.face_names_history = self.face_names_history[:new_max_faces] + + new_bg_color = APP_CONFIG.get("thumbnails_bg_color", + constants.THUMBNAILS_BG_COLOR_DEFAULT) + self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};") + + # Reload filmstrip position so it applies to new viewers + self.filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom") + + # Trigger a repaint to apply other color changes like filename color + self._apply_global_stylesheet() + self.thumbnail_view.updateGeometries() + self.thumbnail_view.viewport().update() + + def open_sidebar_tab(self, index): + """Shows the dock and switches to the specified tab index.""" + self.main_dock.show() + self.tags_tabs.setCurrentIndex(index) + self.main_dock.raise_() + self.on_tags_tab_changed(index) + + def refresh_shortcuts(self): + """Saves current shortcuts configuration and updates running viewers.""" + self.save_config() + for viewer in self.viewers: + if isinstance(viewer, ImageViewer): + viewer.refresh_shortcuts() + + def clean_thumbnail_cache(self): + """Starts a background thread to clean invalid entries from the cache.""" + self.status_lbl.setText(UITexts.CACHE_CLEANING) + self.cache_cleaner = CacheCleaner(self.cache) + self.cache_cleaner.finished_clean.connect(self.on_cache_cleaned) + self.cache_cleaner.finished.connect(self._on_cache_cleaner_finished) + self.cache_cleaner.start() + + def on_cache_cleaned(self, count): + """Slot for when the cache cleaning is finished.""" + self.status_lbl.setText(UITexts.CACHE_CLEANED.format(count)) + + def _on_cache_cleaner_finished(self): + """Clears the cleaner reference only when the thread has truly exited.""" + self.cache_cleaner = None + + def load_more_images(self): + """Requests the scanner to load the next batch of images.""" + batch_size = APP_CONFIG.get( + "scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"]) + self.request_more_images(batch_size) + + def load_all_images(self): + """Toggles the automatic loading of all remaining images.""" + if self._is_loading_all: + # If already loading all, cancel it + self._is_loading_all = False + if self.scanner: + self.scanner.set_auto_load(False) + self.update_load_all_button_state() + else: + # Start loading all remaining images + remaining = self._scanner_total_files - self._scanner_last_index + if remaining > 0: + self._is_loading_all = True + if self.scanner: + self.scanner.set_auto_load(True) + self.update_load_all_button_state() + batch_size = APP_CONFIG.get( + "scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"]) + self.request_more_images(batch_size) + + def perform_shutdown(self): + """Performs cleanup operations before the application closes.""" + self.is_cleaning = True + # 1. Stop all worker threads interacting with the cache + + # Signal all threads to stop first + if self.scanner: + self.scanner.stop() + if self.thumbnail_generator and self.thumbnail_generator.isRunning(): + self.thumbnail_generator.stop() + + # Create a list of threads to wait for + threads_to_wait = [] + if self.scanner and self.scanner.isRunning(): + 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() + threads_to_wait.append(self.cache_cleaner) + + # Wait for them to finish while keeping the UI responsive + if threads_to_wait: + self.status_lbl.setText(UITexts.SHUTTING_DOWN) + QApplication.setOverrideCursor(Qt.WaitCursor) + + for thread in threads_to_wait: + while thread.isRunning(): + QApplication.processEvents() + QThread.msleep(50) # Prevent high CPU usage + + 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.""" + self.perform_shutdown() + QApplication.quit() + + def on_view_double_clicked(self, proxy_index): + """Handles double-clicking on a thumbnail to open the viewer.""" + item_type = self.proxy_model.data(proxy_index, ITEM_TYPE_ROLE) + if item_type == 'thumbnail': + self.open_viewer(proxy_index) + elif item_type == 'header': + group_name = self.proxy_model.data(proxy_index, GROUP_NAME_ROLE) + if group_name: + self.toggle_group_collapse(group_name) + else: + # Fallback for old headers if any + self.proxy_model.invalidate() + + def get_current_selected_path(self): + """Returns the file path of the first selected item in the view.""" + selected_indexes = self.thumbnail_view.selectedIndexes() + if selected_indexes: + proxy_index = selected_indexes[0] + return self.proxy_model.data(proxy_index, PATH_ROLE) + return None + + def get_all_image_paths(self): + """Returns a list of all image paths in the source model.""" + paths = [] + for row in range(self.thumbnail_model.rowCount()): + item = self.thumbnail_model.item(row) + if item and item.data(ITEM_TYPE_ROLE) == 'thumbnail': + paths.append(item.data(PATH_ROLE)) + return paths + + def get_visible_image_paths(self): + """Return a list of all currently visible image paths from the proxy model.""" + if self._visible_paths_cache is not None: + return self._visible_paths_cache + + # Optimization: Filter found_items_data in Python instead of iterating Qt model + # rows which is slow due to overhead. + paths = [] + name_filter = self.proxy_model.name_filter + include_tags = self.proxy_model.include_tags + exclude_tags = self.proxy_model.exclude_tags + match_mode = self.proxy_model.match_mode + collapsed_groups = self.proxy_model.collapsed_groups + + is_grouped = (self.proxy_model.group_by_folder or + self.proxy_model.group_by_day or + self.proxy_model.group_by_week or + self.proxy_model.group_by_month or + self.proxy_model.group_by_year or + self.proxy_model.group_by_rating) + + for item in self.found_items_data: + # item: (path, qi, mtime, tags, rating, inode, dev) + path = item[0] + + if is_grouped: + # Check collapsed groups + _, group_name = self._get_group_info(path, item[2], item[4]) + if group_name in collapsed_groups: + continue + + if name_filter and name_filter not in os.path.basename(path).lower(): + continue + + tags = set(item[3]) if item[3] else set() + show = False + if not include_tags: + show = True + elif match_mode == "AND": + show = include_tags.issubset(tags) + else: # OR mode + show = not include_tags.isdisjoint(tags) + + if show and (not exclude_tags or exclude_tags.isdisjoint(tags)): + paths.append(path) + + self._visible_paths_cache = paths + return paths + + def keyPressEvent(self, e): + """Handles key presses for grid navigation.""" + # If in the search input, do not process grid navigation keys + if self.search_input.lineEdit().hasFocus(): + return + + if self.proxy_model.rowCount() == 0: + return + + current_proxy_idx = self.thumbnail_view.currentIndex() + if not current_proxy_idx.isValid(): + current_proxy_idx = self.proxy_model.index(0, 0) + + current_vis_row = current_proxy_idx.row() + + total_visible = self.proxy_model.rowCount() + grid_size = self.thumbnail_view.gridSize() + if grid_size.width() == 0: + return + cols = max(1, self.thumbnail_view.viewport().width() // grid_size.width()) + next_vis_row = current_vis_row + + # Calculate next position based on key press + if e.key() == Qt.Key_Right: + next_vis_row += 1 + elif e.key() == Qt.Key_Left: + next_vis_row -= 1 + elif e.key() == Qt.Key_Down: + next_vis_row += cols + elif e.key() == Qt.Key_Up: + next_vis_row -= cols + elif e.key() in (Qt.Key_Return, Qt.Key_Enter): + if current_proxy_idx.isValid(): + self.open_viewer(current_proxy_idx) + return + else: + return + + # Clamp the next index within valid bounds + if next_vis_row < 0: + next_vis_row = 0 + if next_vis_row >= total_visible: + # If at the end, try to load more images + if self._scanner_last_index < self._scanner_total_files: + self.request_more_images(1) + next_vis_row = total_visible - 1 + + target_proxy_index = self.proxy_model.index(next_vis_row, 0) + if target_proxy_index.isValid(): + self.set_selection(target_proxy_index, modifiers=e.modifiers()) + + def handle_page_nav(self, key): + """Handles PageUp/PageDown navigation in the thumbnail grid.""" + if self.proxy_model.rowCount() == 0: + return + + current_proxy_idx = self.thumbnail_view.currentIndex() + if not current_proxy_idx.isValid(): + current_proxy_idx = self.proxy_model.index(0, 0) + + current_vis_row = current_proxy_idx.row() + total_visible = self.proxy_model.rowCount() + + grid_size = self.thumbnail_view.gridSize() + if grid_size.width() <= 0 or grid_size.height() <= 0: + # Fallback to delegate size hint if grid size is not set + grid_size = self.delegate.sizeHint(None, None) + + if grid_size.width() <= 0 or grid_size.height() <= 0: + return + + # Calculate how many items fit in one page + cols = max(1, self.thumbnail_view.viewport().width() // grid_size.width()) + rows_visible = max(1, self.thumbnail_view.viewport().height() + // grid_size.height()) + step = cols * rows_visible + + next_vis_idx = current_vis_row + + if key == Qt.Key_PageUp: + next_vis_idx = max(0, current_vis_row - step) + else: + next_vis_idx = min(total_visible - 1, current_vis_row + step) + # If we try to page down past the end, load more images + if next_vis_idx == total_visible - 1 \ + and self._scanner_last_index < self._scanner_total_files: + if current_vis_row + step >= total_visible: + self.request_more_images(step) + + target_proxy_index = self.proxy_model.index(next_vis_idx, 0) + if target_proxy_index.isValid(): + self.set_selection(target_proxy_index, + modifiers=QApplication.keyboardModifiers()) + + def set_selection(self, proxy_index, modifiers=Qt.NoModifier): + """ + Sets the selection in the thumbnail view, handling multi-selection. + + Args: + proxy_index (QModelIndex): The index in the proxy model to select. + modifiers (Qt.KeyboardModifiers): Keyboard modifiers for selection mode. + """ + if not proxy_index.isValid(): + return + + selection_model = self.thumbnail_view.selectionModel() + selection_flags = QItemSelectionModel.NoUpdate + + # Determine selection flags based on keyboard modifiers + if modifiers == Qt.NoModifier: + selection_flags = QItemSelectionModel.ClearAndSelect + elif modifiers & Qt.ControlModifier: + selection_flags = QItemSelectionModel.Toggle + elif modifiers & Qt.ShiftModifier: + # QListView handles range selection automatically with this flag + selection_flags = QItemSelectionModel.Select + + selection_model.select(proxy_index, selection_flags) + self.thumbnail_view.setCurrentIndex(proxy_index) + self.thumbnail_view.scrollTo(proxy_index, QAbstractItemView.EnsureVisible) + + def find_and_select_path(self, path_to_select): + """Finds an item by its path in the model and selects it using a cache.""" + if not path_to_select or path_to_select not in self._path_to_model_index: + return False + + persistent_index = self._path_to_model_index[path_to_select] + if not persistent_index.isValid(): + # The index might have become invalid (e.g., item removed) + del self._path_to_model_index[path_to_select] + return False + + source_index = QModelIndex(persistent_index) # Convert back to QModelIndex + proxy_index = self.proxy_model.mapFromSource(source_index) + + if proxy_index.isValid(): + self.set_selection(proxy_index) + return True + + return False + + def toggle_visibility(self): + """Toggles the visibility of the main window, opening a viewer if needed.""" + if self.isVisible(): + # Find first thumbnail to ensure there are images and to auto-select + first_thumb_idx = None + for row in range(self.proxy_model.rowCount()): + idx = self.proxy_model.index(row, 0) + if self.proxy_model.data(idx, ITEM_TYPE_ROLE) == 'thumbnail': + first_thumb_idx = idx + break + + if not first_thumb_idx: + return + + if not self.thumbnail_view.selectedIndexes(): + self.set_selection(first_thumb_idx) + + # If hiding and no viewers are open, open one for the selected image + open_viewers = [w for w in QApplication.topLevelWidgets() + if isinstance(w, ImageViewer) and w.isVisible()] + + if not open_viewers and self.thumbnail_view.selectedIndexes(): + self.open_viewer(self.thumbnail_view.selectedIndexes()[0]) + self.hide() + elif open_viewers: + self.hide() + else: + self.show() + self.raise_() + self.activateWindow() + self.setFocus() + + def delete_current_image(self, permanent=False): + """Deletes the selected image(s), either to trash or permanently.""" + selected_indexes = self.thumbnail_view.selectedIndexes() + if not selected_indexes: + return + + # For now, only handle single deletion + path = self.proxy_model.data(selected_indexes[0], PATH_ROLE) + + if permanent: + # Confirm permanent deletion + 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))) + confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + confirm.setDefaultButton(QMessageBox.No) + if confirm.exec() != QMessageBox.Yes: + return + + 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): + if path in w.controller.image_list: + try: + deleted_idx = w.controller.image_list.index(path) + new_list = list(w.controller.image_list) + new_list.remove(path) + w.refresh_after_delete(new_list, deleted_idx) + 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: + del self._path_to_model_index[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) + # Clean up group cache + keys_to_remove = [k for k in self._group_info_cache if k[0] == path] + for k in keys_to_remove: + del self._group_info_cache[k] + + # Clean up proxy model cache + if path in self.proxy_model._data_cache: + del self.proxy_model._data_cache[path] + + self._visible_paths_cache = None + except Exception as e: + QMessageBox.critical( + self, UITexts.SYSTEM_ERROR, UITexts.ERROR_DELETING_FILE.format(e)) + + def move_current_image(self): + """Moves the selected image to another directory via a file dialog.""" + path = self.get_current_selected_path() + if not path: + return + target_dir = QFileDialog.getExistingDirectory( + self, UITexts.CONTEXT_MENU_MOVE_TO, os.path.dirname(path)) + + if not target_dir: + return + + new_path = os.path.join(target_dir, os.path.basename(path)) + if os.path.exists(new_path): + reply = QMessageBox.question( + self, UITexts.CONFIRM_OVERWRITE_TITLE, + UITexts.CONFIRM_OVERWRITE_TEXT.format(new_path), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply != QMessageBox.Yes: + return + + try: + shutil.move(path, new_path) + + # Find and remove item from model + for row in range(self.thumbnail_model.rowCount()): + item = self.thumbnail_model.item(row) + if item and item.data(PATH_ROLE) == path: + self.thumbnail_model.removeRow(row) + if path in self._path_to_model_index: + del self._path_to_model_index[path] + break + + # 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) + # Clean up group cache + keys_to_remove = [k for k in self._group_info_cache if k[0] == path] + for k in keys_to_remove: + del self._group_info_cache[k] + + # Clean up proxy model cache + if path in self.proxy_model._data_cache: + del self.proxy_model._data_cache[path] + + self._visible_paths_cache = None + + # Notify viewers + for w in QApplication.topLevelWidgets(): + if isinstance(w, ImageViewer): + if path in w.controller.image_list: + new_list = list(w.controller.image_list) + new_list.remove(path) + w.refresh_after_delete(new_list, -1) + + self.status_lbl.setText(UITexts.MOVED_TO.format(target_dir)) + + except Exception as e: + QMessageBox.critical(self, UITexts.ERROR, UITexts.ERROR_MOVE_FILE.format(e)) + + def copy_current_image(self): + """Copies the selected image to another directory via a file dialog.""" + path = self.get_current_selected_path() + if not path: + return + + target_dir = QFileDialog.getExistingDirectory( + self, UITexts.CONTEXT_MENU_COPY_TO, os.path.dirname(path)) + + if not target_dir: + return + + new_path = os.path.join(target_dir, os.path.basename(path)) + if os.path.exists(new_path): + reply = QMessageBox.question( + self, UITexts.CONFIRM_OVERWRITE_TITLE, + UITexts.CONFIRM_OVERWRITE_TEXT.format(new_path), + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply != QMessageBox.Yes: + return + + try: + shutil.copy2(path, new_path) + self.status_lbl.setText(UITexts.COPIED_TO.format(target_dir)) + + except Exception as e: + QMessageBox.critical(self, UITexts.ERROR, UITexts.ERROR_COPY_FILE.format(e)) + + def rotate_current_image(self, degrees): + """Rotates the selected image, attempting lossless rotation for JPEGs.""" + path = self.get_current_selected_path() + if not path: + return + + _, ext = os.path.splitext(path) + ext = ext.lower() + success = False + + # Try lossless rotation for JPEGs using exiftran if available + if ext in ['.jpg', '.jpeg']: + try: + cmd = [] + if degrees == 90: + cmd = ["exiftran", "-i", "-9", path] + elif degrees == -90: + cmd = ["exiftran", "-i", "-2", path] + elif degrees == 180: + cmd = ["exiftran", "-i", "-1", path] + + if cmd: + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + success = True + except Exception: + pass # Fallback to lossy rotation + + # Fallback to lossy rotation using QImage + if not success: + try: + reader = QImageReader(path) + reader.setAutoTransform(True) + img = reader.read() + if img.isNull(): + return + + transform = QTransform().rotate(degrees) + new_img = img.transformed(transform, Qt.SmoothTransformation) + new_img.save(path) + success = True + except Exception as e: + QMessageBox.critical(self, UITexts.ERROR, + UITexts.ERROR_ROTATE_IMAGE.format(e)) + return + + # Invalidate all cached thumbnails for this path. They will be regenerated + # on demand. + self.cache.invalidate_path(path) + try: + reader = QImageReader(path) + reader.setAutoTransform(True) + full_img = reader.read() + if not full_img.isNull(): + # Regenerate the smallest thumbnail for immediate UI update + stat_res = os.stat(path) + new_mtime = stat_res.st_mtime + new_inode = stat_res.st_ino + new_dev = stat_res.st_dev + + smallest_size = min(SCANNER_GENERATE_SIZES) \ + if SCANNER_GENERATE_SIZES else THUMBNAIL_SIZES[0] + thumb_img = full_img.scaled(smallest_size, smallest_size, + Qt.KeepAspectRatio, Qt.SmoothTransformation) + self.cache.set_thumbnail(path, thumb_img, new_mtime, smallest_size, + inode=new_inode, device_id=new_dev) + + # Update model item + for row in range(self.thumbnail_model.rowCount()): + item = self.thumbnail_model.item(row) + if item and item.data(PATH_ROLE) == path: + item.setIcon(QIcon(QPixmap.fromImage(thumb_img))) + item.setData(new_mtime, MTIME_ROLE) + item.setData(new_inode, INODE_ROLE) + item.setData(new_dev, DEVICE_ROLE) + self._update_internal_data(path, qi=thumb_img, mtime=new_mtime, + inode=new_inode, dev=new_dev) + break + except Exception: + pass + + # Update any open viewers showing this image + for w in QApplication.topLevelWidgets(): + if isinstance(w, ImageViewer): + if w.controller.get_current_path() == path: + w.load_and_fit_image() + + def start_scan(self, paths, sync_viewer=False, active_viewer=None, + select_path=None): + """ + Starts a new background scan for images. + + Args: + paths (list): A list of file paths or directories to scan. + sync_viewer (bool): If True, avoids clearing the grid. + active_viewer (ImageViewer): A viewer to sync with the scan results. + select_path (str): A path to select automatically after the scan finishes. + """ + self.is_cleaning = True + self._suppress_updates = True + if self.scanner: + self.scanner.stop() + + # Reset state for the new scan + self._is_loading_all = APP_CONFIG.get( + "scan_full_on_start", SCANNER_SETTINGS_DEFAULTS["scan_full_on_start"]) + self.update_load_all_button_state() + + # Clear the model if not syncing with an existing viewer + if not sync_viewer: + self.thumbnail_model.clear() + self.found_items_data = [] + self._path_to_model_index.clear() + self._known_paths.clear() + self._group_info_cache.clear() + self.proxy_model.clear_cache() + self._model_update_queue.clear() + self._model_update_timer.stop() + + # Stop any pending hide action from previous scan + self.hide_progress_timer.stop() + + # Hide load buttons during scan + self.btn_load_more.hide() + self.btn_load_all.hide() + self.progress_bar.setValue(0) + self.progress_bar.setCustomColor(None) + self.progress_bar.show() + + self.is_cleaning = False + self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all, + viewers=self.viewers) + if self._is_loading_all: + self.scanner.set_auto_load(True) + self._is_loading = True + self.scanner.images_found.connect(self.collect_found_images) + self.scanner.progress_percent.connect(self.update_progress_bar) + self.scanner.progress_msg.connect(self.status_lbl.setText) + self.scanner.more_files_available.connect(self.more_files_available) + self.scanner.finished_scan.connect( + lambda n: self._on_scan_finished(n, select_path)) + self.scanner.start() + self._scan_all = False + + def _on_scan_finished(self, n, select_path=None): + """Slot for when the image scanner has finished.""" + self._suppress_updates = False + self._scanner_last_index = self._scanner_total_files + + self.btn_load_more.hide() + self.btn_load_all.hide() + + # Turn green to indicate success and hide after 2 seconds + self.progress_bar.setValue(100) + self.progress_bar.setCustomColor(QColor("#2ecc71")) + self.hide_progress_timer.start(2000) + + self.status_lbl.setText(UITexts.DONE_SCAN.format(n)) + self.setFocus() + self._scan_all = False + + # Reset 'load all' state + self._is_loading_all = False + self.update_load_all_button_state() + + # Update dock widgets if visible + if self.main_dock.isVisible(): + self.update_tag_list() + if self.tag_edit_widget.isVisible(): + self.update_tag_edit_widget() + + # Select a specific path if requested (e.g., after layout restore) + if select_path: + self.find_and_select_path(select_path) + + # Final rebuild to ensure all items are correctly placed + if self.rebuild_timer.isActive(): + self.rebuild_timer.stop() + self.rebuild_view() + + def more_files_available(self, i, count): + """Slot for when a batch of images has been loaded, with more available.""" + self._scanner_last_index = i + self._scanner_total_files = count + self._is_loading = False + has_more = i < count + self.btn_load_more.setVisible(has_more) + self.btn_load_all.setVisible(has_more) + + def request_more_images(self, amount): + """Requests the scanner to load a specific number of additional images.""" + if self._is_loading: + return + if self._scanner_last_index >= self._scanner_total_files: + return + self._is_loading = True + self.scanner.load_images(self._scanner_last_index, amount) + + def _incremental_add_to_model(self, batch): + """Appends new items directly to the model without full rebuild.""" + self._visible_paths_cache = None + new_items = [] + for item in batch: + path, qi, mtime, tags, rating, inode, dev = item + new_item = self._create_thumbnail_item( + path, qi, mtime, os.path.dirname(path), + tags, rating, inode, dev) + new_items.append(new_item) + + if new_items: + # Disable updates briefly to prevent flickering during insertion + self.thumbnail_view.setUpdatesEnabled(False) + # Optimization: Use appendRow/insertRow with the item directly. + # This avoids the "insert empty -> set data" double-signaling which forces + # the ProxyModel to filter every row twice. + for item in new_items: + self.thumbnail_model.appendRow(item) + path = item.data(PATH_ROLE) + source_index = self.thumbnail_model.indexFromItem(item) + self._path_to_model_index[path] = QPersistentModelIndex(source_index) + self.thumbnail_view.setUpdatesEnabled(True) + + def collect_found_images(self, batch) -> None: + """Collects a batch of found image data and triggers a view rebuild. + + This method adds new data to an internal list and then starts a timer + to rebuild the view in a debounced manner, improving UI responsiveness + during a scan. + + Args: + batch (list): A list of tuples, where each tuple contains the data + for one found image (path, QImage, mtime, tags, rating). + """ + # Add to the data collection, avoiding duplicates + unique_batch = [] + is_first_batch = len(self.found_items_data) == 0 + + for item in batch: + path = item[0] + if path not in self._known_paths: + self._known_paths.add(path) + # Optimization: Do not store QImage in found_items_data to save memory. + # The delegate will retrieve thumbnails from cache. + unique_batch.append( + (item[0], None, item[2], item[3], item[4], item[5], item[6])) + + # Update proxy filter cache incrementally as data arrives + self.proxy_model.add_to_cache(item[0], item[3]) + + if unique_batch: + self.found_items_data.extend(unique_batch) + + if is_first_batch: + self._suppress_updates = False + + # Adjust rebuild timer interval based on total items to ensure UI + # responsiveness. + # Processing large lists takes time, so we update less frequently as the + # list grows. + total_count = len(self.found_items_data) + if total_count < 2000: + self.rebuild_timer.setInterval(150) + elif total_count < 10000: + self.rebuild_timer.setInterval(500) + else: + self.rebuild_timer.setInterval(1000) + + # Optimization: If scanning and in Flat view, just append to model + # This avoids O(N) rebuilds/sorting during load + is_flat_view = (self.view_mode_combo.currentIndex() == 0) + + # For the very first batch, rebuild immediately to give instant feedback. + if is_first_batch: + self.rebuild_view() + self.rebuild_timer.start() + elif is_flat_view: + # Buffer updates to avoid freezing the UI with thousands of signals + self._model_update_queue.extend(unique_batch) + if not self._model_update_timer.isActive(): + self._model_update_timer.start() + # For grouped views or subsequent batches, debounce to avoid freezing. + elif not self.rebuild_timer.isActive(): + self.rebuild_timer.start() + + def _update_internal_data(self, path, qi=None, mtime=None, tags=None, rating=None, + inode=None, dev=None): + """Updates the internal data list to match model changes.""" + for i, item_data in enumerate(self.found_items_data): + if item_data[0] == path: + # tuple: (path, qi, mtime, tags, rating, inode, dev) + # curr_qi = item_data[1] + curr_mtime = item_data[2] + curr_tags = item_data[3] + curr_rating = item_data[4] + # Preserve inode and dev if available (indices 5 and 6) + curr_inode = item_data[5] if len(item_data) > 5 else None + curr_dev = item_data[6] if len(item_data) > 6 else None + + new_qi = None + new_mtime = mtime if mtime is not None else curr_mtime + new_tags = tags if tags is not None else curr_tags + new_rating = rating if rating is not None else curr_rating + new_inode = inode if inode is not None else curr_inode + new_dev = dev if dev is not None else curr_dev + + # Check if sorting/grouping keys (mtime, rating) changed + if (mtime is not None and mtime != curr_mtime) or \ + (rating is not None and rating != curr_rating): + cache_key = (path, curr_mtime, curr_rating) + if cache_key in self._group_info_cache: + del self._group_info_cache[cache_key] + + self.found_items_data[i] = (path, new_qi, new_mtime, + new_tags, new_rating, new_inode, new_dev) + break + + def _match_item(self, target, item): + """Checks if a data target matches a model item.""" + if item is None: + return False + + # Check for Header match + # target format: ('HEADER', (key, header_text, count)) + if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER': + _, (_, header_text, _) = target + # Strict match including group name to ensure roles are updated + target_group_name = target[1][0] + return (item.data(ITEM_TYPE_ROLE) == 'header' and + item.data(GROUP_NAME_ROLE) == target_group_name and + item.data(DIR_ROLE) == header_text) + + # Check for Thumbnail match + # target format: (path, qi, mtime, tags, rating, inode, dev) + # Target tuple length is now 7 + if item.data(ITEM_TYPE_ROLE) == 'thumbnail' and len(target) >= 5: + return item.data(PATH_ROLE) == target[0] + + return False + + def _get_group_info(self, path, mtime, rating): + """Calculates the grouping key and display name for a file. + + Args: + path (str): File path. + mtime (float): Modification time. + rating (int): Rating value. + + Returns: + tuple: (stable_group_key, display_name) + """ + cache_key = (path, mtime, rating) + if cache_key in self._group_info_cache: + return self._group_info_cache[cache_key] + + stable_group_key = None + display_name = None + + if self.proxy_model.group_by_folder: + stable_group_key = display_name = os.path.dirname(path) + elif self.proxy_model.group_by_day: + stable_group_key = display_name = datetime.fromtimestamp( + mtime).strftime("%Y-%m-%d") + elif self.proxy_model.group_by_week: + dt = datetime.fromtimestamp(mtime) + stable_group_key = dt.strftime("%Y-%W") + display_name = UITexts.GROUP_BY_WEEK_FORMAT.format( + year=dt.strftime("%Y"), week=dt.strftime("%W")) + elif self.proxy_model.group_by_month: + dt = datetime.fromtimestamp(mtime) + stable_group_key = dt.strftime("%Y-%m") + display_name = dt.strftime("%B %Y").capitalize() + elif self.proxy_model.group_by_year: + stable_group_key = display_name = datetime.fromtimestamp( + mtime).strftime("%Y") + elif self.proxy_model.group_by_rating: + r = rating if rating is not None else 0 + stars = (r + 1) // 2 + stable_group_key = str(stars) + display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars) + + self._group_info_cache[cache_key] = (stable_group_key, display_name) + return stable_group_key, display_name + + def rebuild_view(self, full_reset=False): + """ + Sorts all collected image data and rebuilds the source model, inserting + headers for folder groups if required. + """ + # On startup, the view mode is not always correctly applied visually + # even if the combo box shows the correct value. This ensures the view's + # layout properties (grid size, uniform items) are in sync with the + # selected mode before any model rebuild. + self._suppress_updates = True + index = self.view_mode_combo.currentIndex() + + self._model_update_queue.clear() + self._model_update_timer.stop() + + # Update proxy model flags to ensure they match the UI state + self.proxy_model.group_by_folder = (index == 1) + self.proxy_model.group_by_day = (index == 2) + self.proxy_model.group_by_week = (index == 3) + self.proxy_model.group_by_month = (index == 4) + self.proxy_model.group_by_year = (index == 5) + self.proxy_model.group_by_rating = (index == 6) + + is_grouped = index > 0 + self.thumbnail_view.setUniformItemSizes(not is_grouped) + if is_grouped: + self.thumbnail_view.setGridSize(QSize()) + else: + self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) + + # Preserve selection + selected_path = self.get_current_selected_path() + + mode = self.sort_combo.currentText() + rev = "↓" in mode + sort_by_name = "Name" in mode + + # 2. Sort the collected data. Python's sort is stable, so we apply sorts + # from least specific to most specific. + + # First, sort by the user's preference (name or date). + def user_sort_key(data_tuple): + path, _, mtime, _, _, _, _ = data_tuple + if sort_by_name: + return os.path.basename(path).lower() + # Handle None mtime safely for sort + return mtime if mtime is not None else 0 + + self.found_items_data.sort(key=user_sort_key, reverse=rev) + + # 3. Rebuild the model. Disable view updates for a massive performance boost. + self.thumbnail_view.setUpdatesEnabled(False) + + target_structure = [] + + if not is_grouped: + # OPTIMIZATION: In Flat View, rely on Proxy Model for sorting. + # This avoids expensive O(N) source model reshuffling/syncing on the main + # thread. + + sort_role = Qt.DisplayRole if sort_by_name else MTIME_ROLE + sort_order = Qt.DescendingOrder if rev else Qt.AscendingOrder + self.proxy_model.setSortRole(sort_role) + self.proxy_model.sort(0, sort_order) + + # Only rebuild source if requested or desynchronized (e.g. first batch) + # If items were added incrementally, count matches and we skip rebuild. + if full_reset or \ + self.thumbnail_model.rowCount() != len(self.found_items_data): + self.thumbnail_model.clear() + self._path_to_model_index.clear() + # Fast append of all items + for item_data in self.found_items_data: + # item structure: (path, qi, mtime, tags, rating, inode, dev) + p, q, m, t, r, ino, d = item_data + new_item = self._create_thumbnail_item( + p, q, m, os.path.dirname(p), t, r, ino, d) + self.thumbnail_model.appendRow(new_item) + path = new_item.data(PATH_ROLE) + source_index = self.thumbnail_model.indexFromItem(new_item) + self._path_to_model_index[path] = \ + QPersistentModelIndex(source_index) + + self._suppress_updates = False + self.apply_filters() + self.thumbnail_view.setUpdatesEnabled(True) + self.find_and_select_path(selected_path) + + if self.main_dock.isVisible() and \ + self.tags_tabs.currentWidget() == self.filter_widget: + if not self.filter_refresh_timer.isActive(): + self.filter_refresh_timer.start() + return + else: + # For Grouped View, we must ensure source model order matches groups/headers + self.proxy_model.sort(-1) # Disable proxy sorting + + if full_reset: + self.thumbnail_model.clear() + self._path_to_model_index.clear() + + # Optimize grouped insertion: Decorate-Sort-Group + # 1. Decorate: Calculate group info once per item + decorated_data = [] + for item in self.found_items_data: + # item structure: (path, qi, mtime, tags, rating, inode, dev) + stable_key, display_name = self._get_group_info( + item[0], item[2], item[4]) + # Use empty string for None keys to ensure sortability + sort_key = stable_key if stable_key is not None else "" + decorated_data.append((sort_key, display_name, item)) + + # 2. Sort by group key (stable sort preserves previous user order) + is_reverse_group = not self.proxy_model.group_by_folder + decorated_data.sort(key=lambda x: x[0], reverse=is_reverse_group) + + # Update master list to reflect the new group order + self.found_items_data = [x[2] for x in decorated_data] + + # 3. Group and Insert + for _, group_iter in groupby(decorated_data, key=lambda x: x[0]): + group_list = list(group_iter) + if not group_list: + continue + + # Extract info from the first item in the group + _, display_name_group, _ = group_list[0] + count = len(group_list) + + header_text = (UITexts.GROUP_HEADER_FORMAT_SINGULAR if count == 1 + else UITexts.GROUP_HEADER_FORMAT).format( + group_name=display_name_group, count=count) + + # ('HEADER', (key, header_text, count)) + target_structure.append( + ('HEADER', (display_name_group, header_text, count))) + + # Add items from the group + target_structure.extend([x[2] for x in group_list]) + + # 4. Synchronize model with target_structure + model_idx = 0 + target_idx = 0 + total_targets = len(target_structure) + new_items_batch = [] + + while target_idx < total_targets: + target = target_structure[target_idx] + current_item = self.thumbnail_model.item(model_idx) + + if self._match_item(target, current_item): + model_idx += 1 + target_idx += 1 + else: + # Prepare new item + if isinstance(target, tuple) and len(target) == 2 \ + and target[0] == 'HEADER': + _, (group_name, header_text, _) = target + new_item = QStandardItem() + new_item.setData('header', ITEM_TYPE_ROLE) + new_item.setData(header_text, DIR_ROLE) + new_item.setData(group_name, GROUP_NAME_ROLE) + new_item.setFlags(Qt.ItemIsEnabled) + else: + path, qi, mtime, tags, rating, inode, dev = target + new_item = self._create_thumbnail_item( + path, qi, mtime, os.path.dirname(path), + tags, rating, inode, dev) + + # Detect continuous block of new items for batch insertion + new_items_batch = [new_item] + target_idx += 1 + + # Look ahead to see if next items are also new (not in current model) + # This optimization drastically reduces proxy model recalculations + while target_idx < total_targets: + next_target = target_structure[target_idx] + # Check if next_target matches current model position (re-sync) + if self._match_item( + next_target, self.thumbnail_model.item(model_idx)): + break + + # If not matching, it's another new item to insert + if isinstance(next_target, tuple) and len(next_target) == 2 \ + and next_target[0] == 'HEADER': + _, (h_group, h_text, _) = next_target + n_item = QStandardItem() + n_item.setData('header', ITEM_TYPE_ROLE) + n_item.setData(h_text, DIR_ROLE) + n_item.setData(h_group, GROUP_NAME_ROLE) + n_item.setFlags(Qt.ItemIsEnabled) + new_items_batch.append(n_item) + else: + p, q, m, t, r, ino, d = next_target + n_item = self._create_thumbnail_item( + p, q, m, os.path.dirname(p), t, r, ino, d) + new_items_batch.append(n_item) + target_idx += 1 + + # Perform batch insertion + # Optimization: Use appendRow/insertRow with the item directly to avoid + # double-signaling (rowsInserted + dataChanged) which forces the + # ProxyModel to filter every row twice. + if model_idx >= self.thumbnail_model.rowCount(): + for item in new_items_batch: + self.thumbnail_model.appendRow(item) + if item.data(ITEM_TYPE_ROLE) == 'thumbnail': + path = item.data(PATH_ROLE) + source_index = self.thumbnail_model.indexFromItem(item) + self._path_to_model_index[path] = QPersistentModelIndex( + source_index) + else: + for i, item in enumerate(new_items_batch): + self.thumbnail_model.insertRow(model_idx + i, item) + if item.data(ITEM_TYPE_ROLE) == 'thumbnail': + path = item.data(PATH_ROLE) + source_index = self.thumbnail_model.index(model_idx + i, 0) + self._path_to_model_index[path] = QPersistentModelIndex( + source_index) + + model_idx += len(new_items_batch) + + # Remove any remaining trailing items in the model (e.g. if list shrank) + if model_idx < self.thumbnail_model.rowCount(): + for row in range(model_idx, self.thumbnail_model.rowCount()): + item = self.thumbnail_model.item(row) + if item and item.data(ITEM_TYPE_ROLE) == 'thumbnail': + path = item.data(PATH_ROLE) + if path in self._path_to_model_index: + # Only delete if it points to this specific row (stale) + # otherwise we might delete the index for a newly inserted item + p_idx = self._path_to_model_index[path] + if not p_idx.isValid() or p_idx.row() == row: + del self._path_to_model_index[path] + self.thumbnail_model.removeRows( + model_idx, self.thumbnail_model.rowCount() - model_idx) + + self._suppress_updates = False + self.apply_filters() + self.thumbnail_view.setUpdatesEnabled(True) + self.find_and_select_path(selected_path) + + if self.main_dock.isVisible() and \ + self.tags_tabs.currentWidget() == self.filter_widget: + if not self.filter_refresh_timer.isActive(): + self.filter_refresh_timer.start() + + def _create_thumbnail_item(self, path, qi, mtime, dir_path, + tags, rating, inode=None, dev=None): + """Helper to create a standard item for the model.""" + thumb_item = QStandardItem() + # Optimization: Do NOT create QIcon/QPixmap here. + # The delegate handles painting from cache directly. + # This avoids expensive main-thread image conversions during scanning. + # thumb_item.setIcon(QIcon(QPixmap.fromImage(qi))) + thumb_item.setText(os.path.basename(path)) + tooltip_text = f"{os.path.basename(path)}\n{path}" + if tags: + display_tags = [t.split('/')[-1] for t in tags] + tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}" + thumb_item.setToolTip(tooltip_text) + thumb_item.setEditable(False) + thumb_item.setData('thumbnail', ITEM_TYPE_ROLE) + thumb_item.setData(path, PATH_ROLE) + thumb_item.setData(mtime, MTIME_ROLE) + thumb_item.setData(dir_path, DIR_ROLE) + if qi: + thumb_item.setData(qi, IMAGE_DATA_ROLE) + # Set metadata that was loaded in the background thread + thumb_item.setData(tags, TAGS_ROLE) + thumb_item.setData(rating, RATING_ROLE) + + if inode is not None and dev is not None: + thumb_item.setData(inode, INODE_ROLE) + thumb_item.setData(dev, DEVICE_ROLE) + return thumb_item + + def update_progress_bar(self, value): + """Updates the circular progress bar value.""" + self.progress_bar.setValue(value) + + def on_thumbnail_loaded(self, path, size): + """Called when a thumbnail has been loaded asynchronously from DB.""" + self.thumbnail_view.viewport().update() + + def on_tags_tab_changed(self, index): + """Updates the content of the sidebar dock when the active tab changes.""" + widget = self.tags_tabs.widget(index) + if widget == self.tag_edit_widget: + self.tag_edit_widget.load_available_tags() + self.update_tag_edit_widget() + elif widget == self.filter_widget: + self.update_tag_list() + elif widget == self.info_widget: + self.update_info_widget() + + def update_tag_edit_widget(self): + """Updates the tag editor widget with data from the currently selected files.""" + if self._suppress_updates: + return + selected_indexes = self.thumbnail_view.selectedIndexes() + if not selected_indexes: + self.tag_edit_widget.set_files_data({}) + return + + files_data = {} + for proxy_idx in selected_indexes: + path = proxy_idx.data(PATH_ROLE) + tags = proxy_idx.data(TAGS_ROLE) + files_data[path] = tags + self.tag_edit_widget.set_files_data(files_data) + + def update_info_widget(self): + """Updates the information widget (rating, comment) with the current file's + data.""" + if self._suppress_updates: + return + selected_indexes = self.thumbnail_view.selectedIndexes() + paths = [] + if selected_indexes: + for proxy_idx in selected_indexes: + path = self.proxy_model.data(proxy_idx, PATH_ROLE) + if path: + paths.append(path) + self.rating_widget.set_files(paths) + self.comment_widget.set_files(paths) + + def toggle_main_dock(self): + """Toggles the visibility of the main sidebar dock widget.""" + if self.main_dock.isVisible(): + self.main_dock.hide() + else: + self.update_tag_list() + self.main_dock.show() + if self.tag_edit_widget.isVisible(): + self.update_tag_edit_widget() + + def toggle_faces(self): + """Toggles the global 'show_faces' state and updates open viewers.""" + self.show_faces = not self.show_faces + self.save_config() + for viewer in self.viewers: + if isinstance(viewer, ImageViewer): + viewer.controller.show_faces = self.show_faces + viewer.update_view(resize_win=False) + + def on_tags_edited(self, tags_per_file=None): + """Callback to update model items after their tags have been edited.""" + for proxy_idx in self.thumbnail_view.selectedIndexes(): + source_idx = self.proxy_model.mapToSource(proxy_idx) + item = self.thumbnail_model.itemFromIndex(source_idx) + if item: + path = item.data(PATH_ROLE) + # Use provided tags if available, otherwise fallback to disk read + try: + if isinstance(tags_per_file, dict) and path in tags_per_file: + tags = tags_per_file[path] + else: + raw = os.getxattr(path, XATTR_NAME).decode('utf-8') + tags = sorted( + list(set(t.strip() for t in raw.split(',') if t.strip()))) + item.setData(tags, TAGS_ROLE) + + tooltip_text = f"{os.path.basename(path)}\n{path}" + if tags: + display_tags = [t.split('/')[-1] for t in tags] + tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join( + display_tags)}" + item.setToolTip(tooltip_text) + except Exception: + item.setData([], TAGS_ROLE) + + self._update_internal_data(path, tags=item.data(TAGS_ROLE)) + + # Update proxy filter cache immediately + self.proxy_model.add_to_cache(path, tags) + + # Notify the view that the data has changed + self.thumbnail_model.dataChanged.emit( + source_idx, source_idx, [TAGS_ROLE]) + + self.update_tag_list() + self.apply_filters() + + def on_rating_edited(self): + """Callback to update a model item after its rating has been edited.""" + # The rating widget acts on the selected files, so we iterate through them. + for proxy_idx in self.thumbnail_view.selectedIndexes(): + source_idx = self.proxy_model.mapToSource(proxy_idx) + item = self.thumbnail_model.itemFromIndex(source_idx) + if item: + path = item.data(PATH_ROLE) + # Re-read rating from xattr to be sure of the value + new_rating = 0 + try: + raw_rating = os.getxattr(path, RATING_XATTR_NAME).decode('utf-8') + new_rating = int(raw_rating) + except (OSError, ValueError, AttributeError): + pass + + # Update the model data, which will trigger a view update. + item.setData(new_rating, RATING_ROLE) + + self._update_internal_data(path, rating=new_rating) + + def update_tag_list(self): + """Updates the list of available tags in the filter panel from all loaded + items.""" + if not hasattr(self, 'tags_list'): + return + if self._suppress_updates: + return + checked_tags = set() + not_tags = set() + # Preserve current filter state + for i in range(self.tags_list.rowCount()): + item_tag = self.tags_list.item(i, 0) + item_not = self.tags_list.item(i, 1) + + tag_name = item_tag.data(Qt.UserRole) if item_tag else None + if not tag_name and item_tag: + tag_name = item_tag.text() + + if item_tag and item_tag.checkState() == Qt.Checked: + checked_tags.add(tag_name) + if item_tag and item_not and item_not.checkState() == Qt.Checked: + not_tags.add(tag_name) + + self.tags_list.blockSignals(True) + self.tags_list.setRowCount(0) + + # Gather all unique tags from found_items_data (Optimized) + tag_counts = {} + for item in self.found_items_data: + # item structure: (path, qi, mtime, tags, rating, inode, dev) + tags = item[3] + if tags: + for tag in tags: + tag_counts[tag] = tag_counts.get(tag, 0) + 1 + + # Repopulate the filter list + sorted_tags = sorted(list(tag_counts.keys())) + self.tags_list.setRowCount(len(sorted_tags)) + + for i, tag in enumerate(sorted_tags): + count = tag_counts[tag] + display_text = f"{tag} ({count})" + + item = QTableWidgetItem(display_text) + item.setData(Qt.UserRole, tag) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item.setCheckState(Qt.Checked if tag in checked_tags else Qt.Unchecked) + self.tags_list.setItem(i, 0, item) + item_not = QTableWidgetItem() + item_not.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled) + item_not.setCheckState(Qt.Checked if tag in not_tags else Qt.Unchecked) + self.tags_list.setItem(i, 1, item_not) + + self.tags_list.blockSignals(False) + + if hasattr(self, 'tag_search_input'): + self.filter_tags_list(self.tag_search_input.text()) + + def filter_tags_list(self, text): + """Filters the rows in the tags list table based on the search text.""" + search_text = text.strip().lower() + for row in range(self.tags_list.rowCount()): + item = self.tags_list.item(row, 0) + if item: + should_show = search_text in item.text().lower() + self.tags_list.setRowHidden(row, not should_show) + + def on_tag_changed(self, item): + """Handles checkbox changes in the tag filter list.""" + # When a checkbox is checked, uncheck the other in the same row to make + # them mutually exclusive (a tag can't be both included and excluded). + if item.checkState() == Qt.Checked: + self.tags_list.blockSignals(True) + row = item.row() + if item.column() == 0: # 'Tag' checkbox + other_item = self.tags_list.item(row, 1) # 'NOT' checkbox + if other_item: + other_item.setCheckState(Qt.Unchecked) + elif item.column() == 1: # 'NOT' checkbox + other_item = self.tags_list.item(row, 0) # 'Tag' checkbox + if other_item: + other_item.setCheckState(Qt.Unchecked) + self.tags_list.blockSignals(False) + + self.apply_filters() + + def on_selection_changed(self, selected, deselected): + """Callback to update dock widgets when the thumbnail selection changes.""" + if self.tag_edit_widget.isVisible(): + self.update_tag_edit_widget() + elif self.info_widget.isVisible(): + self.update_info_widget() + + def invert_tag_selection(self): + """Inverts the selection of the 'include' checkboxes in the filter.""" + self.tags_list.blockSignals(True) + for i in range(self.tags_list.rowCount()): + item = self.tags_list.item(i, 0) + if item.flags() & Qt.ItemIsUserCheckable: + new_state = Qt.Unchecked \ + if item.checkState() == Qt.Checked else Qt.Checked + item.setCheckState(new_state) + self.tags_list.blockSignals(False) + self.apply_filters() + + def apply_filters(self): + """Applies all current name and tag filters to the proxy model.""" + # Ensure UI components are initialized + if not hasattr(self, 'tags_list') or \ + not hasattr(self, 'filter_mode_group') or \ + not self.filter_mode_group.buttons(): + return + + if self.is_cleaning or self._suppress_updates: + return + + # Preserve selection + selected_path = self.get_current_selected_path() + + # Gather filter criteria from the UI + include_tags = set() + exclude_tags = set() + for i in range(self.tags_list.rowCount()): + item_tag = self.tags_list.item(i, 0) + item_not = self.tags_list.item(i, 1) + + tag_name = item_tag.data(Qt.UserRole) + + if item_tag.checkState() == Qt.Checked: + include_tags.add(tag_name) + if item_not.checkState() == Qt.Checked: + exclude_tags.add(tag_name) + + # Set filter properties on the proxy model + self.proxy_model.include_tags = include_tags + self.proxy_model.exclude_tags = exclude_tags + name_filter = self.filter_name_input.text().strip().lower() + self.proxy_model.name_filter = name_filter + self.proxy_model.match_mode = "AND" \ + if self.filter_mode_group.buttons()[0].isChecked() else "OR" + + # Invalidate the model to force a re-filter + self.proxy_model.invalidate() + self._visible_paths_cache = None + + # Update UI with filter statistics + visible_count = self.proxy_model.rowCount() + total_count = self.thumbnail_model.rowCount() + hidden_count = total_count - visible_count + + if hidden_count > 0: + self.filter_stats_lbl.setText( + UITexts.FILTER_STATS_HIDDEN.format(hidden_count)) + self.filter_stats_lbl.show() + else: + self.filter_stats_lbl.hide() + + is_filter_active = bool(include_tags or exclude_tags or name_filter) + if is_filter_active: + self.filtered_count_lbl.setText( + UITexts.FILTERED_COUNT.format(visible_count)) + else: + self.filtered_count_lbl.setText(UITexts.FILTERED_ZERO) + + # Restore selection if it's still visible + if selected_path: + self.find_and_select_path(selected_path) + + # Sync open viewers with the new list of visible paths + visible_paths = self.get_visible_image_paths() + self.update_viewers_filter(visible_paths) + + def update_viewers_filter(self, visible_paths): + """Updates all open viewers with the new filtered list of visible paths.""" + for w in list(self.viewers): + try: + if not isinstance(w, ImageViewer) or not w.isVisible(): + continue + except RuntimeError: + # The widget was deleted, remove it from the list + self.viewers.remove(w) + continue + + # Get tags and rating for the current image in this viewer + current_path_in_viewer = w.controller.get_current_path() + viewer_tags = [] + viewer_rating = 0 + if current_path_in_viewer in self._known_paths: + for item_data in self.found_items_data: + if item_data[0] == current_path_in_viewer: + viewer_tags = item_data[3] + viewer_rating = item_data[4] + break + # Optimization: avoid update if list is identical + current_path = w.controller.get_current_path() + target_list = visible_paths + new_index = -1 + + if current_path: + try: + new_index = target_list.index(current_path) + except ValueError: + # Current image not in list. + # Check if it was explicitly filtered out (known but hidden) or + # just not loaded yet. + is_filtered = current_path in self._known_paths + + if is_filtered and target_list: + # Filtered out: Move to nearest available neighbor + new_index = min(w.controller.index, len(target_list) - 1) + else: + # Not known (loading) or filtered but list empty: Preserve it + target_list = list(visible_paths) + target_list.append(current_path) + new_index = len(target_list) - 1 + + w.controller.update_list( + target_list, new_index if new_index != -1 else None) + + # Pass current image's tags and rating to the controller + w.controller.update_list( + target_list, new_index if new_index != -1 else None, + viewer_tags, viewer_rating) + if not w._is_persistent and not w.controller.image_list: + w.close() + continue + + w.populate_filmstrip() + + # Reload image if it changed, otherwise just sync selection + if not w._is_persistent and w.controller.get_current_path() != current_path: + w.load_and_fit_image() + else: + w.sync_filmstrip_selection(w.controller.index) + + def _setup_viewer_sync(self, viewer): + """Connects viewer signals to main window slots for selection + synchronization.""" + + def sync_selection_from_viewer(*args): + """Synchronize selection from a viewer to the main window. + + If the image from the viewer is not found in the main view, it may + be because the main view has been filtered. This function will + attempt to resynchronize the viewer's image list. + """ + path = viewer.controller.get_current_path() + if not path: + return + + # First, try to select the image directly. This is the common case + if self.find_and_select_path(path): + return # Success, image was found and selected. + + # If not found, it might be because the main view is filtered. + # Attempt to resynchronize the viewer's list with the current view. + # We perform the check inside the lambda to ensure it runs AFTER the sync. + def sync_and_retry(): + self.update_viewers_filter(self.get_visible_image_paths()) + if not self.find_and_select_path(path): + self.status_lbl.setText(UITexts.IMAGE_NOT_IN_VIEW.format( + os.path.basename(path))) + + QTimer.singleShot(0, sync_and_retry) + + # This signal is emitted when viewer.controller.index changes + viewer.index_changed.connect(sync_selection_from_viewer) + viewer.activated.connect(sync_selection_from_viewer) + + def open_viewer(self, proxy_index, persistent=False): + """ + Opens a new image viewer for the selected item. + + Args: + proxy_index (QModelIndex): The index of the item in the proxy model. + persistent (bool): Whether the viewer is part of a persistent layout. + """ + if not proxy_index.isValid() or \ + self.proxy_model.data(proxy_index, ITEM_TYPE_ROLE) != 'thumbnail': + return + + visible_paths = self.get_visible_image_paths() + + # The index of the clicked item in the visible list is NOT its row in the + # proxy model when headers are present. We must find it. + clicked_path = self.proxy_model.data(proxy_index, PATH_ROLE) + try: + new_idx = visible_paths.index(clicked_path) + except ValueError: + return # Should not happen if get_visible_image_paths is correct + + if not visible_paths: + return + + # Get tags and rating for the current image + current_image_data = self.found_items_data[self.found_items_data.index( + next(item for item in self.found_items_data if item[0] == clicked_path))] + initial_tags = current_image_data[3] + initial_rating = current_image_data[4] + viewer = ImageViewer(self.cache, visible_paths, new_idx, + initial_tags, initial_rating, self, persistent=persistent) + + self._setup_viewer_sync(viewer) + self.viewers.append(viewer) + viewer.destroyed.connect( + lambda: self.viewers.remove(viewer) if viewer in self.viewers else None) + 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): + try: + with open(HISTORY_PATH, 'r') as f: + self.full_history = json.load(f) + except Exception: + self.full_history = [] + else: + self.full_history = [] + + def save_full_history(self): + """Saves the persistent browsing/search history to its JSON file.""" + try: + with open(HISTORY_PATH, 'w') as f: + json.dump(self.full_history, f, indent=4) + except Exception: + pass + + def add_to_history(self, term): + """Adds a new term to both the recent (in-memory) and persistent history.""" + # Update recent items in the ComboBox + if term in self.history: + self.history.remove(term) + self.history.insert(0, term) + if len(self.history) > 25: + self.history = self.history[:25] + self.save_config() + + # Update the full, persistent history + entry = { + "path": term, + "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + # Remove duplicate to bump it to the top + self.full_history = [x for x in self.full_history if x['path'] != term] + self.full_history.insert(0, entry) + self.save_full_history() + + if hasattr(self, 'history_tab'): + self.history_tab.refresh_list() + + def add_to_mru_tags(self, tag): + """Adds a tag to the Most Recently Used list.""" + if tag in self.mru_tags: + self.mru_tags.remove(tag) + self.mru_tags.appendleft(tag) + self.save_config() # Save on change + + def update_metadata_for_path(self, path, metadata=None): + """Finds an item by path and updates its metadata in the model and internal + data.""" + if not path: + return + + # Find the item in the source model and update its data + for row in range(self.thumbnail_model.rowCount()): + item = self.thumbnail_model.item(row) + if item and item.data(PATH_ROLE) == path: + # Reload metadata for this item from xattr + try: + if metadata and 'tags' in metadata: + tags = metadata['tags'] + else: # Fallback to reading from disk if not provided + raw = XattrManager.get_attribute(path, XATTR_NAME) + tags = sorted(list(set(t.strip() for t in raw.split(',') + if t.strip()))) if raw else [] + item.setData(tags, TAGS_ROLE) + except Exception: + item.setData([], TAGS_ROLE) + try: + item.setData(metadata.get('rating', 0) + if metadata else 0, RATING_ROLE) + except Exception: + item.setData(0, RATING_ROLE) # Default to 0 if error + + # Notify the view that the data has changed + source_idx = self.thumbnail_model.indexFromItem(item) + self.thumbnail_model.dataChanged.emit( + source_idx, source_idx, [TAGS_ROLE, RATING_ROLE]) + + # Update internal data structure to prevent stale data on rebuild + current_tags = item.data(TAGS_ROLE) + current_rating = item.data(RATING_ROLE) + self._update_internal_data(path, tags=current_tags, + rating=current_rating) + + # Update proxy filter cache to prevent stale filtering + self.proxy_model.add_to_cache(path, current_tags) + break + + if self.main_dock.isVisible(): + self.on_tags_tab_changed(self.tags_tabs.currentIndex()) + + # Re-apply filters in case the tag change affects visibility + self.apply_filters() + + def on_view_mode_changed(self, index): + """Callback for when the view mode (Flat/Folder) changes.""" + self._suppress_updates = True + self.proxy_model.group_by_folder = (index == 1) + self.proxy_model.group_by_day = (index == 2) + self.proxy_model.group_by_week = (index == 3) + self.proxy_model.group_by_month = (index == 4) + self.proxy_model.group_by_year = (index == 5) + self.proxy_model.group_by_rating = (index == 6) + + self.proxy_model.collapsed_groups.clear() + self._group_info_cache.clear() + + is_grouped = index > 0 + + # Disable uniform item sizes in grouped modes to allow headers of different + # height + self.thumbnail_view.setUniformItemSizes(not is_grouped) + + if is_grouped: + self.thumbnail_view.setGridSize(QSize()) + else: + self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) + self.rebuild_view(full_reset=True) + + self.update_tag_list() + + self.save_config() + self.setFocus() + + def on_sort_changed(self): + """Callback for when the sort order dropdown changes.""" + self.rebuild_view(full_reset=True) + self.save_config() + if hasattr(self, 'history_tab'): + self.history_tab.refresh_list() + self.setFocus() + + def _get_tier_for_size(self, requested_size): + """Determines the ideal thumbnail tier based on the requested size.""" + if requested_size < 192: + return 128 + if requested_size < 320: + return 256 + return 512 + + def on_slider_changed(self, v): + """Callback for when the thumbnail size slider changes.""" + self.current_thumb_size = v + self.size_label.setText(f"{v}px") + + new_tier = self._get_tier_for_size(v) + + # If the required tier for the new size is different, we trigger generation. + if new_tier != self._current_thumb_tier: + self._current_thumb_tier = new_tier + + # 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] + + # 2. For all images ALREADY loaded, start a background job to + # generate the newly required thumbnail size. This is interruptible. + self.generate_missing_thumbnails(new_tier) + + # Update the delegate's size hint and the view's grid size + new_hint = self.delegate.sizeHint(None, None) + + is_grouped = (self.proxy_model.group_by_folder or + self.proxy_model.group_by_day or + self.proxy_model.group_by_week or + self.proxy_model.group_by_month or + self.proxy_model.group_by_year or + self.proxy_model.group_by_rating) + + if is_grouped: + self.thumbnail_view.setGridSize(QSize()) + self.thumbnail_view.doItemsLayout() + else: + self.thumbnail_view.setGridSize(new_hint) + self.thumbnail_view.update() + self.setFocus() + + def generate_missing_thumbnails(self, size): + """ + Starts a background thread to generate thumbnails of a specific size for all + currently loaded images. + """ + if self.thumbnail_generator and self.thumbnail_generator.isRunning(): + self.thumbnail_generator.stop() + + paths = self.get_all_image_paths() + if not paths: + return + + self.thumbnail_generator = ThumbnailGenerator(self.cache, paths, size) + self.thumbnail_generator.generation_complete.connect( + self.on_high_res_generation_finished) + self.thumbnail_generator.progress.connect( + lambda p, t: self.status_lbl.setText( + f"Generating {size}px thumbnails: {p}/{t}") + ) + self.thumbnail_generator.start() + + def on_high_res_generation_finished(self): + """Slot called when the background thumbnail generation is complete.""" + self.status_lbl.setText(UITexts.HIGH_RES_GENERATED) + self.thumbnail_view.viewport().update() + + def refresh_content(self): + """Refreshes the current view by re-running the last search or scan.""" + if not self.history: + return + + current_selection = self.get_current_selected_path() + term = self.history[0] + if term.startswith("file:/"): + path = term[6:] + if os.path.isfile(path): + self.start_scan([os.path.dirname(path)], select_path=current_selection) + return + self.process_term(term, select_path=current_selection) + + def process_term(self, term, select_path=None): + """Processes a search term, file path, or layout directive.""" + self.add_to_history(term) + self.update_search_input() + + if term.startswith("layout:/"): + if not self.is_xcb: + return + + # Handle loading a layout + filename = os.path.join(LAYOUTS_DIR, f"{term[8:]}") + base, ext = os.path.splitext(filename) + if ext != ".layout": + filename = filename + ".layout" + + if os.path.exists(filename): + self.restore_layout(filename) + else: + self.is_cleaning = True + if self.scanner: + self.scanner.stop() + self.scanner.wait() + QMessageBox.critical(self, + UITexts.ERROR_LOADING_LAYOUT_TITLE.format( + PROG_NAME), + UITexts.ERROR_LOADING_LAYOUT_TEXT.format(term[8:])) + QApplication.quit() + + else: + # Handle a file path or search query + if term.startswith("search:/"): + path = term[8:] + else: + path = term[6:] if term.startswith("file:/") else term + if os.path.isfile(path): + # If a single file is passed, open it in a viewer and scan its directory + self.thumbnail_model.clear() + self.active_viewer = ImageViewer(self.cache, [path], 0, + initial_tags=None, + initial_rating=0, parent=self, + persistent=True) + self.start_scan([os.path.dirname(path)], + active_viewer=self.active_viewer) + self._setup_viewer_sync(self.active_viewer) + self.viewers.append(self.active_viewer) + self.active_viewer.destroyed.connect( + lambda obj=self.active_viewer: self.viewers.remove(obj) + if obj in self.viewers else None) + self.active_viewer.show() + + else: + # If a directory or search term, start a scan + self.start_scan([path], select_path=select_path) + + def update_search_input(self): + """Updates the search input combo box with history items and icons.""" + self.search_input.clear() + for h in self.history: + icon = QIcon.fromTheme("system-search") + text = h.replace("search:/", "").replace("file:/", "") + + if h.startswith("file:/"): + path = h[6:] + if os.path.isdir(os.path.expanduser(path)): + icon = QIcon.fromTheme("folder") + else: + icon = QIcon.fromTheme("image-x-generic") + elif h.startswith("layout:/"): + icon = QIcon.fromTheme("view-grid") + elif h.startswith("search:/"): + icon = QIcon.fromTheme("system-search") + + self.search_input.addItem(icon, text) + + def on_search_triggered(self): + """Callback for when a search is triggered from the input box.""" + t = self.search_input.currentText().strip() + if t: + # Detect if the term is an existing path or a search query + self.process_term(f"file:/{t}" if os.path.exists( + os.path.expanduser(t)) else f"search:/{t}") + + def select_directory(self): + """Opens a file dialog to select an image or directory.""" + dialog = QFileDialog(self) + dialog.setWindowTitle(UITexts.SELECT_IMAGE_TITLE) + default_folder = os.path.expanduser("~") + + dialog.setDirectory(default_folder) + dialog.setFileMode(QFileDialog.ExistingFile) + # Don't force. + # dialog.setOption(QFileDialog.Option.DontUseNativeDialog, False) + + dialog.setNameFilters([IMAGE_MIME_TYPES]) + + if self.scanner and self.scanner._is_running: + self.scanner.stop() + self.scanner.wait() + + if dialog.exec(): + selected = dialog.selectedFiles() + if selected: + # Process the first selected file + self.process_term(f"file:/{selected[0]}") + + def load_config(self): + """Loads application settings from the JSON configuration file.""" + d = {} + if os.path.exists(CONFIG_PATH): + try: + with open(CONFIG_PATH, 'r') as f: + d = json.load(f) + except Exception: + pass # Ignore errors in config file + + self.history = d.get("history", []) + self.current_thumb_size = d.get("thumb_size", + THUMBNAILS_DEFAULT_SIZE) + self.slider.setValue(self.current_thumb_size) + self.size_label.setText(f"{self.current_thumb_size}px") + self.sort_combo.setCurrentIndex(d.get("sort_order", 0)) + self.view_mode_combo.setCurrentIndex(d.get("view_mode", 0)) + self.show_viewer_status_bar = d.get("show_viewer_status_bar", True) + self.filmstrip_position = d.get("filmstrip_position", "bottom") + self.show_filmstrip = d.get("show_filmstrip", False) + self.show_faces = d.get("show_faces", False) + if "active_dock_tab" in d: + self.tags_tabs.setCurrentIndex(d["active_dock_tab"]) + self.face_names_history = d.get("face_names_history", []) + self.pet_names_history = d.get("pet_names_history", []) + self.object_names_history = d.get("object_names_history", []) + self.landmark_names_history = d.get("landmark_names_history", []) + + max_tags = APP_CONFIG.get("tags_menu_max_items", TAGS_MENU_MAX_ITEMS_DEFAULT) + self.mru_tags = deque(d.get("mru_tags", []), + maxlen=max_tags) + + self._load_shortcuts_config(d) + + # Restore window geometry and state + if "geometry" in d: + g = d["geometry"] + self.setGeometry(g["x"], g["y"], g["w"], g["h"]) + if "window_state" in d: + self.restoreState( + QByteArray.fromBase64(d["window_state"].encode())) + + def _load_shortcuts_config(self, config_dict): + """Loads global and viewer shortcuts from the configuration dictionary.""" + # Load global shortcuts + self.loaded_global_shortcuts = config_dict.get("global_shortcuts") + + # Load viewer shortcuts + # 1. Load defaults into a temporary dict. + default_shortcuts = {} + for action, (key, mods) in DEFAULT_VIEWER_SHORTCUTS.items(): + if action in VIEWER_ACTIONS: + desc, _ = VIEWER_ACTIONS[action] + default_shortcuts[(int(key), + Qt.KeyboardModifiers(mods))] = (action, desc) + + # 2. Load user's config if it exists. + v_shortcuts = config_dict.get("viewer_shortcuts", []) + if v_shortcuts: + user_shortcuts = { + (k, Qt.KeyboardModifiers(m)): (act, desc) + for (k, m), (act, desc) in v_shortcuts + } + + # 3. Merge: Start with user's config, then add missing defaults. + user_actions = {val[0] for val in user_shortcuts.values()} + user_keys = set(user_shortcuts.keys()) + + for key, (action, desc) in default_shortcuts.items(): + if action not in user_actions and key not in user_keys: + user_shortcuts[key] = (action, desc) + + self.viewer_shortcuts = user_shortcuts + else: + # No user config for viewer shortcuts, just use the defaults. + self.viewer_shortcuts = default_shortcuts + + def save_config(self): + """Saves application settings to the JSON configuration file.""" + # Update the global APP_CONFIG with the current state of the MainWindow + APP_CONFIG["history"] = self.history + APP_CONFIG["thumb_size"] = self.current_thumb_size + APP_CONFIG["sort_order"] = self.sort_combo.currentIndex() + APP_CONFIG["view_mode"] = self.view_mode_combo.currentIndex() + APP_CONFIG["show_viewer_status_bar"] = self.show_viewer_status_bar + APP_CONFIG["filmstrip_position"] = self.filmstrip_position + APP_CONFIG["show_filmstrip"] = self.show_filmstrip + APP_CONFIG["show_faces"] = self.show_faces + APP_CONFIG["window_state"] = self.saveState().toBase64().data().decode() + APP_CONFIG["active_dock_tab"] = self.tags_tabs.currentIndex() + APP_CONFIG["face_names_history"] = self.face_names_history + APP_CONFIG["pet_names_history"] = self.pet_names_history + APP_CONFIG["object_names_history"] = self.object_names_history + APP_CONFIG["landmark_names_history"] = self.landmark_names_history + APP_CONFIG["mru_tags"] = list(self.mru_tags) + + # Save viewer shortcuts as list for JSON serialization + v_shortcuts_list = [] + for (k, m), (act, desc) in self.viewer_shortcuts.items(): + try: + mod_int = int(m) + except TypeError: + mod_int = m.value + v_shortcuts_list.append([[k, mod_int], [act, desc]]) + APP_CONFIG["viewer_shortcuts"] = v_shortcuts_list + + # Save global shortcuts + if hasattr(self, 'shortcut_controller') and self.shortcut_controller: + g_shortcuts_list = [] + for (k, m), (act, ignore, desc, cat) in \ + self.shortcut_controller._shortcuts.items(): + try: + mod_int = int(m) + except TypeError: + mod_int = m.value + 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()} + + constants.save_app_config() + + def resizeEvent(self, e): + """Handles window resize events to trigger a debounced grid refresh.""" + super().resizeEvent(e) + self.thumbnails_refresh_timer.start() + + def eventFilter(self, source, event): + """Filters events from child widgets, like viewport resize.""" + if source is self.thumbnail_view.viewport() and event.type() == QEvent.Resize: + self.thumbnails_refresh_timer.start() + return super().eventFilter(source, event) + + def open_current_folder(self): + """Opens the directory of the selected image in the default file manager.""" + path = self.get_current_selected_path() + if path: + QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path))) + + def handle_initial_args(self, args): + """Handles command-line arguments passed to the application at startup.""" + path = " ".join(args).strip() + full_path = os.path.abspath(os.path.expanduser(path)) + + if os.path.isfile(full_path): + self.add_to_history(f"file:/{full_path}") + # Refresh combo box with history + self.update_search_input() # This is a disk read. + + # Open viewer directly + self.active_viewer = ImageViewer(self.cache, [full_path], 0, + initial_tags=None, initial_rating=0, + parent=self, persistent=True) + self._setup_viewer_sync(self.active_viewer) + self.viewers.append(self.active_viewer) + self.active_viewer.destroyed.connect( + lambda obj=self.active_viewer: self.viewers.remove(obj) + if obj in self.viewers else None) + self.active_viewer.show() + self.hide() # Main window is hidden in direct view mode + + # Scan the file's directory in the background for context + self._scan_all = False + self.start_scan([full_path, str(Path(full_path).parent)], + sync_viewer=True, active_viewer=self.active_viewer) + else: + # If not a file, process as a generic term (path, search, or layout) + term = path if path.startswith(("search:/", "file:/", "layout:/")) \ + else f"file:/{path}" + self.process_term(term) + + def set_app_icon(self): + """Sets the application icon from the current theme.""" + icon = QIcon.fromTheme(ICON_THEME, QIcon.fromTheme(ICON_THEME_FALLBACK)) + self.setWindowIcon(icon) + + # --- Context Menu --- + def show_context_menu(self, pos): + """Shows the context menu for the thumbnail view.""" + menu = QMenu(self) + + # Check if clicked on a header (which isn't usually selectable) + index_at_pos = self.thumbnail_view.indexAt(pos) + if index_at_pos.isValid() and \ + self.proxy_model.data(index_at_pos, ITEM_TYPE_ROLE) == 'header': + group_name = self.proxy_model.data(index_at_pos, GROUP_NAME_ROLE) + if group_name: + action_toggle = menu.addAction("Collapse/Expand Group") + action_toggle.triggered.connect( + lambda: self.toggle_group_collapse(group_name)) + menu.exec(self.thumbnail_view.mapToGlobal(pos)) + return + menu.setStyleSheet("QMenu { border: 1px solid #555; }") + + selected_indexes = self.thumbnail_view.selectedIndexes() + if not selected_indexes: + return + + def add_action_with_shortcut(target_menu, text, icon_name, action_name, slot): + shortcut_str = "" + if action_name and hasattr(self, 'shortcut_controller'): + shortcut_map = self.shortcut_controller.action_to_shortcut + if action_name in shortcut_map: + key, mods = shortcut_map[action_name] + try: + mod_val = int(mods) + except TypeError: + mod_val = mods.value + seq = QKeySequence(mod_val | key) + shortcut_str = seq.toString(QKeySequence.NativeText) + + display_text = f"{text}\t{shortcut_str}" if shortcut_str else text + action = target_menu.addAction(QIcon.fromTheme(icon_name), display_text) + action.triggered.connect(slot) + return action + + action_view = menu.addAction(QIcon.fromTheme("image-x-generic"), + UITexts.CONTEXT_MENU_VIEW) + action_view.triggered.connect(lambda: self.open_viewer(selected_indexes[0])) + + menu.addSeparator() + + selection_menu = menu.addMenu(QIcon.fromTheme("edit-select"), UITexts.SELECT) + add_action_with_shortcut(selection_menu, UITexts.CONTEXT_MENU_SELECT_ALL, + "edit-select-all", "select_all", + self.select_all_thumbnails) + add_action_with_shortcut(selection_menu, UITexts.CONTEXT_MENU_SELECT_NONE, + "edit-clear", "select_none", + self.select_none_thumbnails) + add_action_with_shortcut(selection_menu, + UITexts.CONTEXT_MENU_INVERT_SELECTION, + "edit-select-invert", "invert_selection", + self.invert_selection_thumbnails) + menu.addSeparator() + + open_submenu = menu.addMenu(QIcon.fromTheme("document-open"), + UITexts.CONTEXT_MENU_OPEN) + full_path = os.path.abspath( + self.proxy_model.data(selected_indexes[0], PATH_ROLE)) + self.populate_open_with_submenu(open_submenu, full_path) + + 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) + action_open_location.triggered.connect( + lambda: self.process_term(f"file:/{os.path.dirname(path)}")) + + action_open_default_app = menu.addAction( + QIcon.fromTheme("system-run"), + UITexts.CONTEXT_MENU_OPEN_DEFAULT_APP) + action_open_default_app.triggered.connect( + lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))) + + menu.addSeparator() + + add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_RENAME, "edit-rename", + "rename_image", + lambda: self.rename_image(selected_indexes[0].row())) + action_move = menu.addAction(QIcon.fromTheme("edit-move"), + UITexts.CONTEXT_MENU_MOVE_TO) + action_move.triggered.connect(self.move_current_image) + + action_copy = menu.addAction(QIcon.fromTheme("edit-copy"), + UITexts.CONTEXT_MENU_COPY_TO) + action_copy.triggered.connect(self.copy_current_image) + + menu.addSeparator() + + rotate_menu = menu.addMenu(QIcon.fromTheme("transform-rotate"), + UITexts.CONTEXT_MENU_ROTATE) + + action_rotate_ccw = rotate_menu.addAction(QIcon.fromTheme("object-rotate-left"), + UITexts.CONTEXT_MENU_ROTATE_LEFT) + action_rotate_ccw.triggered.connect(lambda: self.rotate_current_image(-90)) + + action_rotate_cw = rotate_menu.addAction(QIcon.fromTheme("object-rotate-right"), + UITexts.CONTEXT_MENU_ROTATE_RIGHT) + action_rotate_cw.triggered.connect(lambda: self.rotate_current_image(90)) + + menu.addSeparator() + + add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_TRASH, "user-trash", + "move_to_trash", + lambda: self.delete_current_image(permanent=False)) + add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_DELETE, "edit-delete", + "delete_permanently", + lambda: self.delete_current_image(permanent=True)) + menu.addSeparator() + + clipboard_menu = menu.addMenu(QIcon.fromTheme("edit-copy"), + UITexts.CONTEXT_MENU_CLIPBOARD) + + action_copy_url = clipboard_menu.addAction(QIcon.fromTheme("text-html"), + UITexts.CONTEXT_MENU_COPY_URL) + action_copy_url.triggered.connect(self.copy_file_url) + + action_copy_dir = clipboard_menu.addAction(QIcon.fromTheme("folder"), + UITexts.CONTEXT_MENU_COPY_DIR) + action_copy_dir.triggered.connect(self.copy_dir_path) + + menu.addSeparator() + action_regenerate_thumbnail = menu.addAction(UITexts.CONTEXT_MENU_REGENERATE) + action_regenerate_thumbnail.triggered.connect( + lambda: self.regenerate_thumbnail(path)) + + menu.addSeparator() + + action_props = menu.addAction(QIcon.fromTheme("document-properties"), + UITexts.CONTEXT_MENU_PROPERTIES) + action_props.triggered.connect(self.show_properties) + + menu.exec(self.thumbnail_view.mapToGlobal(pos)) + + def toggle_group_collapse(self, group_name): + """Toggles the collapsed state of a group.""" + if group_name in self.proxy_model.collapsed_groups: + self.proxy_model.collapsed_groups.remove(group_name) + else: + self.proxy_model.collapsed_groups.add(group_name) + self.proxy_model.invalidate() + self._visible_paths_cache = None + + def regenerate_thumbnail(self, path): + """Regenerates the thumbnail for the specified path.""" + if not path: + return + + # Create a ThumbnailGenerator to regenerate the thumbnail + size = self._get_tier_for_size(self.current_thumb_size) + self.thumbnail_generator = ThumbnailGenerator(self.cache, [path], size) + self.thumbnail_generator.generation_complete.connect( + self.on_high_res_generation_finished) + self.thumbnail_generator.progress.connect( + lambda p, t: self.status_lbl.setText( + f"Regenerating thumbnail: {p}/{t}") + ) + self.thumbnail_generator.start() + + # Invalidate the cache so the new thumbnail is loaded + self.cache.invalidate_path(path) + self.rebuild_view() + + def get_app_info(self, desktop_file_id): + """Gets the readable name and icon of an application from its .desktop file.""" + if desktop_file_id in self._app_info_cache: + return self._app_info_cache[desktop_file_id] + + desktop_file_id = desktop_file_id.split(':')[-1].strip() + desktop_path = desktop_file_id + if not desktop_path.startswith("/"): + # Search in standard application paths including flatpak/snap/local + search_paths = [ + "/usr/share/applications", + os.path.expanduser("~/.local/share/applications"), + "/usr/local/share/applications", + "/var/lib/flatpak/exports/share/applications", + "/var/lib/snapd/desktop/applications" + ] + + if "XDG_DATA_DIRS" in os.environ: + for path in os.environ["XDG_DATA_DIRS"].split(":"): + if path: + app_path = os.path.join(path, "applications") + if app_path not in search_paths: + search_paths.append(app_path) + + for path in search_paths: + full_p = os.path.join(path, desktop_file_id) + if os.path.exists(full_p): + desktop_path = full_p + break + + name = "" + icon = "" + try: + if os.path.exists(desktop_path): + name = subprocess.check_output( + ["kreadconfig6", "--file", desktop_path, + "--group", "Desktop Entry", "--key", "Name"], + text=True + ).strip() + + icon = subprocess.check_output( + ["kreadconfig6", "--file", desktop_path, + "--group", "Desktop Entry", "--key", "Icon"], + text=True + ).strip() + except Exception: + pass + + if not name: + name = os.path.basename( + desktop_file_id).replace(".desktop", "").capitalize() + + result = (name, icon, desktop_path) + self._app_info_cache[desktop_file_id] = result + return result + + def populate_open_with_submenu(self, menu, full_path): + """Populates the 'Open With' submenu with associated applications.""" + if not full_path: + return + try: + # 1. Get the mimetype of the file + mime_query = subprocess.check_output(["kmimetypefinder", full_path], + text=True).strip() + + if mime_query in self._open_with_cache: + app_entries = self._open_with_cache[mime_query] + else: + # 2. Query for associated applications using 'gio mime' + apps_cmd = ["gio", "mime", mime_query] + output = subprocess.check_output(apps_cmd, text=True).splitlines() + + app_entries = [] + seen_resolved_paths = set() # For deduplication based on resolved path + + for line in output: + line = line.strip() + if ":" not in line and line.endswith(".desktop"): + app_name, icon_name, resolved_path = self.get_app_info(line) + if resolved_path in seen_resolved_paths: + continue + seen_resolved_paths.add(resolved_path) + # Store original line for gtk-launch + app_entries.append((app_name, icon_name, line)) + self._open_with_cache[mime_query] = app_entries + + if not app_entries: + menu.addAction(UITexts.CONTEXT_MENU_NO_APPS_FOUND).setEnabled(False) + else: + for app_name, icon_name, desktop_file_id_from_gio_mime in app_entries: + icon = QIcon.fromTheme(icon_name) if icon_name else QIcon() + action = menu.addAction(icon, app_name) + action.triggered.connect( + lambda checked=False, df=desktop_file_id_from_gio_mime: + subprocess.Popen(["gtk-launch", df, full_path])) + + menu.addSeparator() + action_other = menu.addAction(QIcon.fromTheme("applications-other"), + "Open with other application...") + action_other.triggered.connect( + lambda: self.open_with_system_chooser(full_path)) + except Exception: + action = menu.addAction(UITexts.CONTEXT_MENU_ERROR_LISTING_APPS) + action.setEnabled(False) + + def open_with_system_chooser(self, path): + """Opens the system application chooser using xdg-desktop-portal.""" + if not path: + return + + # Use QDBusMessage directly to avoid binding issues with + # QDBusInterface.asyncCall + msg = QDBusMessage.createMethodCall( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.OpenURI", + "OpenURI" + ) + # Arguments: parent_window (str), uri (str), options (dict/a{sv}) + msg.setArguments(["", QUrl.fromLocalFile(path).toString(), {"ask": True}]) + QDBusConnection.sessionBus().call(msg, QDBus.NoBlock) + + def copy_file_url(self): + """Copies the file URL of the selected image to the clipboard.""" + path = self.get_current_selected_path() + if not path: + return + url = QUrl.fromLocalFile(path) + mime = QMimeData() + mime.setUrls([url]) + mime.setText(url.toString()) + QApplication.clipboard().setMimeData(mime) + + def copy_dir_path(self): + """Copies the directory path of the selected image to the clipboard.""" + path = self.get_current_selected_path() + if not path: + return + QApplication.clipboard().setText(os.path.dirname(path)) + + def show_properties(self): + """Shows the custom properties dialog for the selected file.""" + full_path = self.get_current_selected_path() + if not full_path: + return + full_path = os.path.abspath(full_path) + + # Extract metadata from selected item + tags = [] + rating = 0 + selected_indexes = self.thumbnail_view.selectedIndexes() + if selected_indexes: + idx = selected_indexes[0] + tags = self.proxy_model.data(idx, TAGS_ROLE) + rating = self.proxy_model.data(idx, RATING_ROLE) or 0 + + dlg = PropertiesDialog( + full_path, initial_tags=tags, initial_rating=rating, parent=self) + dlg.exec() + + def clear_thumbnail_cache(self): + """Clears the entire in-memory and on-disk thumbnail cache.""" + confirm = QMessageBox(self) + confirm.setIcon(QMessageBox.Warning) + confirm.setWindowTitle(UITexts.CONFIRM_CLEAR_CACHE_TITLE) + confirm.setText(UITexts.CONFIRM_CLEAR_CACHE_TEXT) + confirm.setInformativeText(UITexts.CONFIRM_CLEAR_CACHE_INFO) + confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + confirm.setDefaultButton(QMessageBox.No) + if confirm.exec() != QMessageBox.Yes: + return + + self.cache.clear_cache() + self.status_lbl.setText(UITexts.CACHE_CLEARED) + + def propagate_rename(self, old_path, new_path, source_viewer=None): + """Propagates a file rename across the application.""" + self._visible_paths_cache = None + # Update found_items_data to ensure consistency on future rebuilds + current_tags = None + for i, item_data in enumerate(self.found_items_data): + if item_data[0] == old_path: + # tuple structure: (path, qi, mtime, tags, rating, inode, dev) + self.found_items_data[i] = (new_path,) + item_data[1:] + current_tags = item_data[3] + self._known_paths.discard(old_path) + self._known_paths.add(new_path) + + # Clean up group cache since the key (path) has changed + 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] + break + + # Update proxy model cache to avoid stale entries + if old_path in self.proxy_model._data_cache: + del self.proxy_model._data_cache[old_path] + if current_tags is not None: + self.proxy_model._data_cache[new_path] = ( + set(current_tags) if current_tags else set(), + os.path.basename(new_path).lower()) + + # Update the main model + for row in range(self.thumbnail_model.rowCount()): + item = self.thumbnail_model.item(row) + if item and item.data(PATH_ROLE) == old_path: + item.setData(new_path, PATH_ROLE) + item.setText(os.path.basename(new_path)) + # No need to update the icon, it's the same image data + source_index = self.thumbnail_model.indexFromItem(item) + self.thumbnail_model.dataChanged.emit(source_index, source_index) + break + + # Update the cache entry + self.cache.rename_entry(old_path, new_path) + + # Update other open viewers + for v in self.viewers: + if v is not source_viewer and isinstance(v, ImageViewer) and v.isVisible(): + if old_path in v.controller.image_list: + try: + idx = v.controller.image_list.index(old_path) + v.controller.image_list[idx] = new_path + if v.controller.index == idx: + v.update_view(resize_win=False) + v.populate_filmstrip() + except ValueError: + pass + + def rename_image(self, proxy_row_index): + """Handles the logic for renaming a file from the main thumbnail view.""" + proxy_index = self.proxy_model.index(proxy_row_index, 0) + if not proxy_index.isValid(): + return + + while True: + old_path = self.proxy_model.data(proxy_index, PATH_ROLE) + if not old_path: + return + old_dir = os.path.dirname(old_path) + old_filename = os.path.basename(old_path) + base_name, extension = os.path.splitext(old_filename) + + new_base, ok = QInputDialog.getText( + self, UITexts.RENAME_FILE_TITLE, + UITexts.RENAME_FILE_TEXT.format(old_filename), + QLineEdit.Normal, base_name + ) + + if ok and new_base and new_base != base_name: + # Re-add extension if the user omitted it + new_base_name, new_extension = os.path.splitext(new_base) + if new_extension == extension: + new_filename = new_base + else: + new_filename = new_base_name + extension + + new_path = os.path.join(old_dir, new_filename) + + if os.path.exists(new_path): + QMessageBox.warning(self, + UITexts.RENAME_ERROR_TITLE, + UITexts.RENAME_ERROR_EXISTS.format( + new_filename)) + # Loop again to ask for a different name + else: + try: + os.rename(old_path, new_path) + self.propagate_rename(old_path, new_path) + self.status_lbl.setText( + UITexts.FILE_RENAMED.format(new_filename)) + break + except Exception as e: + QMessageBox.critical(self, + UITexts.SYSTEM_ERROR, + UITexts.ERROR_RENAME.format(str(e))) + break + else: + break + + def select_all_thumbnails(self): + """Selects all visible items in the thumbnail view.""" + if not self.thumbnail_view.isVisible() or self.proxy_model.rowCount() == 0: + return + selection_model = self.thumbnail_view.selectionModel() + # Create a selection that covers all rows in the proxy model + top_left = self.proxy_model.index(0, 0) + bottom_right = self.proxy_model.index(self.proxy_model.rowCount() - 1, 0) + selection = QItemSelection(top_left, bottom_right) + selection_model.select(selection, QItemSelectionModel.Select) + + def select_none_thumbnails(self): + """Clears the selection in the thumbnail view.""" + if not self.thumbnail_view.isVisible(): + return + self.thumbnail_view.selectionModel().clearSelection() + + def invert_selection_thumbnails(self): + """Inverts the current selection of visible items.""" + if not self.thumbnail_view.isVisible() or self.proxy_model.rowCount() == 0: + return + selection_model = self.thumbnail_view.selectionModel() + + # Get all selectable items + all_items_selection = QItemSelection() + for row in range(self.proxy_model.rowCount()): + index = self.proxy_model.index(row, 0) + if self.proxy_model.data(index, ITEM_TYPE_ROLE) == 'thumbnail': + all_items_selection.select(index, index) + + # Invert the current selection against all selectable items + selection_model.select(all_items_selection, QItemSelectionModel.Toggle) + + def update_load_all_button_state(self): + """Updates the text and tooltip of the 'load all' button based on its state.""" + if self._is_loading_all: + self.btn_load_all.setText("X") + self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP_ALT) + else: + self.btn_load_all.setText("+a") + self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP) + + def _create_language_menu(self): + """Creates the language selection menu and adds it to the menubar.""" + # Assuming you have a settings or view menu. Add it where you see fit. + # If you don't have one, you can add it directly to the menu bar. + settings_menu = self.menuBar().addMenu("&Settings") # Or get an existing menu + + language_menu = settings_menu.addMenu(UITexts.MENU_LANGUAGE) + lang_group = QActionGroup(self) + lang_group.setExclusive(True) + lang_group.triggered.connect(self._on_language_changed) + + for code, name in SUPPORTED_LANGUAGES.items(): + action = QAction(name, self, checkable=True) + action.setData(code) + if code == CURRENT_LANGUAGE: + action.setChecked(True) + language_menu.addAction(action) + lang_group.addAction(action) + + def _on_language_changed(self, action): + """Handles language change, saves config, and prompts for restart.""" + new_lang = action.data() + # Only save and show message if the language actually changed + if new_lang != APP_CONFIG.get("language", CURRENT_LANGUAGE): + APP_CONFIG["language"] = new_lang + constants.save_app_config() + + # Inform user that a restart is needed for the change to take effect + msg_box = QMessageBox(self) + msg_box.setWindowTitle(UITexts.RESTART_REQUIRED_TITLE) + msg_box.setText(UITexts.RESTART_REQUIRED_TEXT.format( + language=action.text())) + msg_box.setIcon(QMessageBox.Information) + msg_box.setStandardButtons(QMessageBox.Ok) + msg_box.exec() + + +def main(): + """The main entry point for the Bagheera Image Viewer application.""" + app = QApplication(sys.argv) + + # Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB + QPixmapCache.setCacheLimit(102400) + + 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) + shortcut_controller = AppShortcutController(win) + win.shortcut_controller = shortcut_controller + app.installEventFilter(shortcut_controller) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/bagheeraview_devel.desktop b/bagheeraview_devel.desktop new file mode 100755 index 0000000..eac51e5 --- /dev/null +++ b/bagheeraview_devel.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +Categories=Graphics;RasterGraphics;Viewer; +Comment[en_US]=Bagheera Image Viewer Devel +Comment=Bagheera Image Viewer Devel +Encoding=UTF-8 +Exec=/home/ignacio/devel/bagheera/bagheeraview/bagheeraview.py %u +GenericName[en_US]=Bagheera Image Viewer Devl +GenericName=Bagheera Image Viewer Devel +Icon=/home/ignacio/devel/bagheera/icons/Gemini_Generated_Image_qgn3p4qgn3p4qgn3.png +MimeType=inode/directory;image/x-xbitmap;image/x-tga;image/x-portable-pixmap;image/x-portable-graymap;image/x-portable-bitmap;image/x-pict;image/webp;image/vnd.zbrush.pcx;image/vnd.adobe.photoshop;image/tiff;image/png;image/jpeg;image/gif;image/bmp; +Name[en_US]=Bagheera Image Viewer Devel +Name=Bagheera Image Viewer Devel +NoDisplay=false +Path= +StartupNotify=true +Terminal=false +TerminalOptions= +Type=Application +X-DBUS-ServiceName= +X-DBUS-StartupType= +X-DCOP-ServiceType= +X-KDE-SubstituteUID=false +X-KDE-Username= diff --git a/baloo_tools b/baloo_tools new file mode 120000 index 0000000..6eb3d1f --- /dev/null +++ b/baloo_tools @@ -0,0 +1 @@ +/home/ignacio/devel/bagheera/bagheerasearch/baloo_tools \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..62acde4 --- /dev/null +++ b/build.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash + +#source .venv/bin/activate + +case $1 in + -v) + shift + case $1 in + 3.8) PYINSTALLER=pyinstaller-3.8;; + 3.9) PYINSTALLER=pyinstaller-3.9;; + 3.10) PYINSTALLER=pyinstaller-3.10;; + 3.11) PYINSTALLER=pyinstaller-3.11;; + 3.12) PYINSTALLER=pyinstaller-3.12;; + 3.13) PYINSTALLER=pyinstaller-3.13;; + 3.14) PYINSTALLER=pyinstaller-3.14;; + *) PYINSTALLER=pyinstaller;; + esac + ;; + --version=3.8) PYINSTALLER=pyinstaller-3.8;; + --version=3.9) PYINSTALLER=pyinstaller-3.9;; + --version=3.10) PYINSTALLER=pyinstaller-3.10;; + --version=3.11) PYINSTALLER=pyinstaller-3.11;; + --version=3.12) PYINSTALLER=pyinstaller-3.12;; + --version=3.13) PYINSTALLER=pyinstaller-3.13;; + --version=3.14) PYINSTALLER=pyinstaller-3.14;; + *) PYINSTALLER=pyinstaller;; +esac + +# $PYINSTALLER \ +# --add-binary 'desktop/Desktogram.png:desktop' \ +# --add-binary 'locale/en/LC_MESSAGES/messages.mo:locale/en/LC_MESSAGES' \ +# --add-binary 'locale/es/LC_MESSAGES/messages.mo:locale/es/LC_MESSAGES' \ +# --add-binary 'locale/gl/LC_MESSAGES/messages.mo:locale/gl/LC_MESSAGES' \ +# --add-data 'js/downloader.js:js' \ +# --noconsole \ +# -F tagmanager.py + +# Sólo en windows. +# --icon=desktop/TagsManager.png \ + +$PYINSTALLER \ + --onefile \ + --noconsole \ + --windowed \ + -F bagheeraview.py + +#deactivate diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..21aaafd --- /dev/null +++ b/changelog.txt @@ -0,0 +1,256 @@ +v0.9.11 - +· Hacer que el image viewer standalone admita múltiles sort +· Comprobar hotkeys y funcionamiento en general. +· Inhibir el salvapantallas con el slideshow y añadir opción de menú para inhibirlo durante un tiempo determinado +· Mejorar el menú Open, con nombres correctos e iconos adecuados + +· Me gustaría que al restaurar un layout, si una imagen no existe, se muestre un aviso en lugar de simplemente omitirla. ¿Puedes implementarlo? +. Me gustaría que el ajuste "Scan Max Level" muestre una advertencia visual si se establece en un valor muy alto (por ejemplo > 5). +· ¿Puedes hacer que el diálogo de selección de etiquetas (cuando hay múltiples coincidencias) muestre una vista previa de una imagen que ya tenga esa etiqueta? +· Me gustaría que el estado del filtro de la vista de miniaturas (tags seleccionados, texto de búsqueda) también se guarde en los layouts. ¿Puedes implementarlo? +· ¿Podrías añadir un botón "Exportar a CSV" en el diálogo de propiedades para guardar todos los metadatos en un archivo? +· Si quisiera distribuir mi aplicación como un AppImage, ¿cómo empaquetaría estos plugins de KDE para que funcionen en otros sistemas? +· Solucionar el problema de las ventanas de diálogo nativas, tan simple como usar PySide nativo. + +Analiza si la estrategia LIFO (Last-In, First-Out) en `CacheLoader` es la ideal para una galería de imágenes o si debería ser mixta. + +¿Cómo puedo añadir una opción para limitar el número de hilos que `ImageScanner` puede usar para la generación de miniaturas? + +Verifica si el uso de `QPixmapCache` en `ThumbnailDelegate.paint_thumbnail` está optimizado para evitar la conversión repetitiva de QImage a QPixmap, lo que podría causar ralentizaciones al hacer scroll rápido. + +Check if the `_suppress_updates` flag correctly prevents potential race conditions in `update_tag_list` when switching view modes rapidly. + +Verify if `find_and_select_path` logic in `on_view_mode_changed` handles cases where the selected item is filtered out after the view mode change. + +cuando se hace una nueva búsqueda que no se refresquen los tags, ni filtros, ni nada hasta que venga la primera imagen de la búsqueda nueva. Actualizar algo que se está destruyendo no tiene sentido. Lo mismo aplica si se cambia la agrupación, paramos las actualizaciones y luego, cuando acabe la agrupación activamos de nuevo los tags y los filtros y todo lo que implique un refresco de pantalla. + +¿Puedes comprobar si la lógica de `ThumbnailSortFilterProxyModel` puede optimizarse aún más, quizás cacheando los resultados de `filterAcceptsRow` para evitar comprobaciones repetitivas cuando no cambian los filtros? + +Me gustaría que el scanner pudiera detectar cambios en el sistema de archivos (inotify/watchdog) y actualizar la vista automáticamente si se añaden imágenes a la carpeta actual. + +Genera una estructura de código segura para PropertiesDialog que cargue los metadatos de forma asíncrona. + +¿Cómo puedo mover la comprobación de animaciones en load_and_fit_image a un hilo secundario para evitar el bloqueo? + +¿Cómo puedo asegurame de que la ventana del visor se abra centrada en la pantalla correcta tras el redimensionado? + +Check if the `CacheWriter` batch processing logic correctly handles empty batches or exceptions to prevent data loss. + +Verifica si el manejo de excepciones en _process_single_image es lo suficientemente robusto para evitar que el hilo de escaneo muera por un archivo corrupto. + +How can I implement a bulk rename feature for the selected pet or face tags? + +¿Cómo puedo añadir una opción "Abrir con otra aplicación..." al final del submenú que lance el selector de aplicaciones del sistema? + +¿Cómo puedo añadir soporte para arrastrar y soltar imágenes desde el visor (ImageViewer) a otras aplicaciones? + +¿Por qué la selección de imágenes se pierde o cambia incorrectamente al cambiar el modo de agrupación? + +¿Por qué al cambiar de "Separar por Carpeta" a "Plano" la selección se pierde a veces? + +Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligente de imágenes grandes en el visor basada en la dirección del movimiento del ratón? + +¿Cómo puedo limitar el tamaño total de la caché en disco a un valor específico (ej. 5GB) y borrar automáticamente las entradas más antiguas (LRU)? + +¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista? + + +v0.9.10 - Eleven step to 1.0 +· Slideshow inverso +· Más mejoras de rendimiento y seguridad +· Mejorado el desplazamiento de la imagen en el image viewer + +v0.9.9 - Ten stet o 1.0 +· Added pets support +· Nueva opción de abrir con otra aplicación +· Mejoras en la configuración + +v0.9.8 - Nine step to 1.0 +· Crop mode +· Muchos cambios y correcciones de bug. + +v0.9.7 - Eight stetp to 1.0 +· Nuevo parámetro --x11 que fuerza que la aplicación use X11 en vez de Xorg. +· Si no estamos en --x11 layout no estará disponible. + +v0.9.6 - Seven step to 1.0 +· Más cambios hechos por la IA para mejorar velocidad y reducir acceso a disco. +· El menú open ahora es mucho mejor y se ha añadido también al image viewer. + +v0.9.5 - Six step to 1.0 +· Alguna mejora y más velocidad, en teoría mucha más velocidad y optimizaciones. +· Nuevas opciones para añadir tags con AND y con OR +· Una porrada de cambios hecho por la IA, a ver en que acaba esta versión al final. + +v0.9.4 - Five step to 1.0 +· Nueva opciones en el menú de ImageViewer. +· Corregido un problema al ocultar la ventana principal sin imágen seleccionada. +· En teoría, mejorada la velocidad en procesos con muchos thumbnails. + +v0.9.3 - Four step to 1.0 +· Cambiado balooctl por una llamada a DBus. +· Baloo search is configurable. +· Fixed bad typo, is "user.xdg.comment" not "user.comment". +· File comment control uses all space available. +· Added to text control delete icon. + +v0.9.2 - Third step to 1.0 +· Added BagueeraSearch lib support + +v0.9.1 - Second step to 1.0 +· Empty comments delete tag instead of saving empty values. +· Se puede decidir que se muestra debajo de los thumbnails. +· Shortcuts code refactorized. +· Más opciones de parametrización. + +v0.9.0 - First step to 1.0 +· Added spport to avoid duplicates on face detection. +· Fixed rename face delete tag even when exists other faces with same name. +· Fixed delete face does not delete associated tag. +· Added tooltip to thumbnails showing full path. +· Minor changes and improvements on properties form. + +v0.1.25 - Last alpha version +· Configuration + +v0.1.24 - Best resolution guess +· Best resolution guess for image viewer +· Initial configuration form + +v0.1.23 - Thumbnails view improved +· New group for day, month, year and rating + +v0.1.22 - More changes. +· Filmstrip position can be changed to top, right, left and bottom. +· Mejora de los menús. Queda el menú open por arreglar. + +v0.1.21 - More changes +· Improve scanning to make applications more responsive +· Fixed rating not updated on thumbnails +· Show faces state is shared on image viewers and saved +· Several changes in image viewer menu +· Added suport to animate gif images +v0.1.20 - Optimización +· Optimizada la carga de thumbnails + +v0.1.19 - Better thumbnail generation +· Cambio en la forma en la que se cargan los thumbnails con tramos de 128, 256 y 512 +· Filmstrip no actualizaba la selección a la imagen visualizada + +v0.1.18 - Mediapipe +· Fast menu seleccionado el primer elemento por defecto +· Cambio de API en mediapipe, a partir de ahora se necesit un fichero +· Fixed if filter is active if tags are changed view and thumbnails must be refreshed +· Fix: al activar los thumbnails si el filtro está activo no muestra nada + +v0.1.17 - Polished +· Fixed issues with fast tag menu +· Improved shortcuts handling +· Fixed hags becouse thumbnail viewe was requesting more images than available + +v0.1.16 - Multilanguage +· Multilanguage: en, es, gl +· Face and tags history items number managed by constact correctly + +v0.1.15 - Better faces and tags handling +· Added new method to name faces with history +· Added menu to fast tag in image viewer +· Fixed same keys does not work on input text controls +· Improved layout save and restore: status bar, film strip, main dock position + +v0.1.14 - Minor improvements +· Added baloosearch as a fallback for bagheerasearch +· Added confirmation to clear cache and clear delete database to relinquish +· Added shortcuts help +· Shortcuts can be changed and saved +· After face detection name is asked to user +· Fixed navigation on thumbnails using page-up and page-down + +v0.1.13 - Minor changesenv +· Fixed filter tab not refreshed if selected +· In properties form grid columns are resizeable +· Added counters to filter tags +· Baloo notified on metadata change + +v0.1.12 - New folder view +· New Folder view in thumbnails form +· Fixed new tag requesting two times the name and ignoring first one +· Fixed faced name must intercept al keys + +v0.1.11 - Face recognition +· Added face recognition initial support + +v0.1.10 - Gui improvements +· Added faces initial support +· New thumbnails form really fast + +v0.1.9 - Scanner and search + · Scanner and search merged + +v0.1.8 - High optimization + · Optimized imageviewer load by disabling all thumbnails generation + +v0.1.7 - So many changes + · Added support to EXIV2 metatada + · Fixed Home/End in image viewer + · Refactoring + · Added LMDB as thumbnails cache + · Shift-Q close all open viewers + · Added comments and ratings + · Added filter count label + · Added filter by filename + +v0.1.6 - To smooth things over + · Buttons to load thumbnails + · Docker size, position and state saved + · Option to mirror in image viewer + · Text string extraction. First step to multilanguage + · Not filter + +v0.1.5 - Gui improvements + · Layout and history grid are resizeable and sortable + · Change named buttons for icon buttons on history and layout + · Fixed fit on load with status bar enabled + · Treeview fixed? + · Rename on image viewer + +v0.1.4 - New features + · Saving/loading thumbnails cache to disk + · Add layout tab + · Add hystory tab + · Limit search combo to default 25 entries + · Fixed loading layouts + +v0.1.3 - Speed improvements + · Tag management speed improved + · Save cache on exit and load on start + · Imageviewer refactorized + · Added filmstrip on imageviewer with drag&drop to other applications + +v0.1.2 - Tags + · Added edit tags to dock + · Added multiple selection to thumbnails + · Added status bar to viewer + +v0.1.1 - Drag to outside + · Added drag from thumbnails + · Fixed KDE properties call + · Added Move to and Copy to options to thumbnails menu + · Added new properties window with metadata basic management, system properties window call was removed + · Added slideshow to image viewer + · Added tags dock with filter + +v0.1.0 - First version + · New proyect using Karousel source code. + · Proyecto comenzado el 21/02/2026. + +BUGS: + · Al cambiar los tags se releen de nuevo lo que produce valores desactualizados. + · Move sólo funciona en X11. + · Si el layout no existe cuando se pasa como parámetro el programa no se cierra. + · Al lanzar una búsqueda a veces aparecen imágenes fantasma anteriores. Thumbnails, lista de ficheros, tags, etc. ¿solucionado? + · ¿¿¿Del no está funcionando bien en el visor, no está borrando lo que está mostrando. No le he reproducido.???1 + · ¿¿¿Está aplicando el scalado del monitor a las imágenes. ¿Es esto realmente un bug???? + +IMPROVEMENTS: + · diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..9a30241 --- /dev/null +++ b/constants.py @@ -0,0 +1,1598 @@ +""" +Constants Module for Bagheera Image Viewer. + +This file centralizes all application-wide constants, settings, and static text. +It is organized into several sections: + +- General application information (name, version). +- Configuration paths for settings and cache files. +- Default values for application behavior (e.g., cache sizes, scanner limits). +- UI-related constants (e.g., icon themes, default sizes). +- A `UITexts` class that provides internationalized strings for the UI based + on the current language configuration. +""" +import importlib.util +import json +import os +import shutil +import sys + +from PySide6.QtCore import Qt + +# --- PLATFORM WORKAROUNDS --- +# Wayland does not reliably support moving/positioning windows programmatically, +# which is used for layout restoration. Forcing X11 via xcb is a workaround. +FORCE_X11 = "--x11" in sys.argv +if FORCE_X11: + os.environ["QT_QPA_PLATFORM"] = "xcb" + +# --- CONFIGURATION --- +PROG_NAME = "Bagheera Image Viewer" +PROG_ID = "bagheeraview" +PROG_VERSION = "0.9.11-dev" +PROG_AUTHOR = "Ignacio Serantes" + +# --- CACHE SETTINGS --- +# Maximum number of thumbnails to keep in the in-memory cache. +CACHE_MAX_SIZE = 20000 +# Maximum size of the persistent disk cache file. +# 10 GB limit for persistent cache file +DISK_CACHE_MAX_BYTES = 10 * 1024 * 1024 * 1024 + +# --- PATHS --- +CONFIG_FILE = f"{PROG_ID}rc" +CONFIG_LOCATION = '.config/iserantes' +CONFIG_DIR = os.path.join(os.path.expanduser("~"), CONFIG_LOCATION, PROG_ID) +CONFIG_PATH = os.path.join(CONFIG_DIR, CONFIG_FILE) +CACHE_PATH = os.path.join(CONFIG_DIR, "thumbnails_lmdb") + +HISTORY_FILE = "history.json" +HISTORY_PATH = os.path.join(CONFIG_DIR, HISTORY_FILE) +LAYOUTS_DIR = os.path.join(CONFIG_DIR, "layouts") # Layouts saving directory + + +def save_app_config(): + """Saves the main application configuration to the JSON file.""" + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(CONFIG_PATH, 'w', encoding='utf-8') as f: + # Use APP_CONFIG global + json.dump(APP_CONFIG, f, indent=4) + except OSError: + # Silently fail for now, but could log this + pass + + +# --- CONFIGURATION LOADING --- +def load_app_config(): + """Loads the main application configuration from the JSON file.""" + if not os.path.exists(CONFIG_PATH): + return {} + try: + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + # In case of error, return empty config to avoid crash + return {} + + +APP_CONFIG = load_app_config() + +# --- UI: ICONS & THEMES --- +ICON_THEME = "bagheeraview" +ICON_THEME_FALLBACK = "org.kde.dolphin" +ICON_THEME_VIEWER = "bagheeraview" +ICON_THEME_VIEWER_FALLBACK = "image" + +# --- FILE HANDLING --- +IMAGE_EXTENSIONS = {'.bmp', '.gif', '.jpeg', '.jpg', '.png', '.tiff', '.webp'} +IMAGE_MIME_TYPES = "Image files (*" + ' *'.join(IMAGE_EXTENSIONS) + ")" + +# Path to KDE's screen configuration file. Used for more accurate screen geometry. +# Maybe needed, maybe not, for calculating screen geometry +KSCREEN_DOCTOR_MARGIN = 0 +KWINOUTPUTCONFIG_PATH = os.path.join(os.path.expanduser("~"), + ".config/kwinoutputconfig.json") + +# --- EXTERNAL TOOLS --- +# Command definitions for external search tools. + +BALOOSEARCH_EXEC = shutil.which("baloosearch") or shutil.which("baloosearch6") +SEARCH_CMD = [BALOOSEARCH_EXEC, "--type", "image"] if BALOOSEARCH_EXEC else None + +# --- TAGS --- +TAGS_MENU_MAX_ITEMS_DEFAULT = 25 + +# --- SCANNER SETTINGS --- +SCANNER_SETTINGS_DEFAULTS = { + "scan_max_level": 2, + "scan_batch_size": 64, + "scan_full_on_start": True, + "person_tags": "", + "generation_threads": 4, + "search_engine": "Native" +} + +# --- IMAGE VIEWER DEFAULTS --- +VIEWER_LABEL = "BagheeraView" +VIEWER_FORM_MARGIN = 10 + +# --- THUMBNAIL GRID DEFAULTS --- +# Default size of the thumbnail widget in the grid view. +THUMBNAILS_DEFAULT_SIZE = 128 +# The different size tiers for thumbnails that can be cached. +THUMBNAIL_SIZES = [128, 256, 512] +# The sizes that the initial scanner will generate. A smaller one for the initial +# grid view, and a larger one to have ready for zooming. +SCANNER_GENERATE_SIZES = [128] +THUMBNAILS_MARGIN = 10 +THUMBNAILS_REFRESH_INTERVAL_DEFAULT = 200 +THUMBNAILS_BG_COLOR_DEFAULT = "#191919" +THUMBNAILS_FILENAME_COLOR_DEFAULT = "#DDDDDD" +THUMBNAILS_TAGS_COLOR_DEFAULT = "#3498db" +THUMBNAILS_RATING_COLOR_DEFAULT = "#f1c40f" +THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT = 8 +THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT = "#DDDDDD" +THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT = "#333333" +THUMBNAILS_TAGS_FONT_SIZE_DEFAULT = 7 +THUMBNAILS_FILENAME_LINES_DEFAULT = 1 +THUMBNAILS_TAGS_LINES_DEFAULT = 2 +VIEWER_WHEEL_SPEED_DEFAULT = 5 +VIEWER_AUTO_RESIZE_WINDOW_DEFAULT = True + +# --- METADATA --- +# The extended attribute name used for storing tags, following the freedesktop.org spec. +XATTR_NAME = "user.xdg.tags" +RATING_XATTR_NAME = "user.baloo.rating" +XATTR_COMMENT_NAME = "user.xdg.comment" + +# --- BEHAVIOR --- +# The initial zoom ratio to use when opening an image, relative to screen size. +ZOOM_DESKTOP_RATIO = 0.93 + +# --- FACES --- +FACES_MENU_MAX_ITEMS_DEFAULT = 25 +FACES_MENU_MAX_ITEMS = APP_CONFIG.get("faces_menu_max_items", + FACES_MENU_MAX_ITEMS_DEFAULT) + +# --- FACE DETECTION --- +HAVE_MEDIAPIPE = False +if importlib.util.find_spec("mediapipe") is not None: + try: + import mediapipe + # Verify that the tasks module (new API) is available + if hasattr(mediapipe, "tasks"): + HAVE_MEDIAPIPE = True + except Exception: + pass +HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None + +MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, + "blaze_face_short_range.tflite") +MEDIAPIPE_FACE_MODEL_URL = ( + "https://storage.googleapis.com/mediapipe-models/face_detector/" + "blaze_face_short_range/float16/1/blaze_face_short_range.tflite" +) + +MEDIAPIPE_OBJECT_MODEL_PATH = os.path.join(CONFIG_DIR, + "efficientdet_lite0.tflite") +MEDIAPIPE_OBJECT_MODEL_URL = ( + "https://storage.googleapis.com/mediapipe-models/object_detector/" + "efficientdet_lite0/float16/1/efficientdet_lite0.tflite" +) + +# Ordered list of available detection engines. The first one found will be the default. +# MediaPipe is generally preferred for its performance. +AVAILABLE_FACE_ENGINES = [] +if HAVE_FACE_RECOGNITION: + AVAILABLE_FACE_ENGINES.append("face_recognition") +if HAVE_MEDIAPIPE: + AVAILABLE_FACE_ENGINES.append("mediapipe") + +AVAILABLE_PET_ENGINES = [] +if HAVE_MEDIAPIPE: + AVAILABLE_PET_ENGINES.append("mediapipe") + +# Determine the default engine. This can be overridden by user config. +DEFAULT_FACE_ENGINE = AVAILABLE_FACE_ENGINES[0] if AVAILABLE_FACE_ENGINES else None +DEFAULT_PET_ENGINE = AVAILABLE_PET_ENGINES[0] if AVAILABLE_PET_ENGINES else None + +DEFAULT_FACE_BOX_COLOR = "#FFFFFF" +# Load preferred engine from config, or use the default. +FACE_DETECTION_ENGINE = APP_CONFIG.get("face_detection_engine", + DEFAULT_FACE_ENGINE) +PET_DETECTION_ENGINE = APP_CONFIG.get("pet_detection_engine", + DEFAULT_PET_ENGINE) + +DEFAULT_PET_BOX_COLOR = "#98FB98" # PaleGreen +DEFAULT_OBJECT_BOX_COLOR = "#FFD700" # Gold +DEFAULT_LANDMARK_BOX_COLOR = "#00BFFF" # DeepSkyBlue +# --- SHORTCUTS --- +GLOBAL_ACTIONS = { + "quit_app": ("Quit Application", "Global"), + "toggle_visibility": ("Toggle Visibility", "Global"), + "close_all_viewers": ("Close All Viewers", "Global"), + "load_more_images": ("Load More Images", "File"), + "load_all_images": ("Load All Images", "File"), + "save_layout": ("Save Layout", "File"), + "load_layout": ("Load Layout", "File"), + "open_folder": ("Open Folder", "File"), + "move_to_trash": ("Move to Trash", "File"), + "delete_permanently": ("Delete Permanently", "File"), + "rename_image": ("Rename Image", "Actions"), + "refresh_content": ("Refresh Content", "Actions"), + "first_image": ("First Image", "Navigation"), + "last_image": ("Last Image", "Navigation"), + "prev_page": ("Previous Page", "Navigation"), + "next_page": ("Next Page", "Navigation"), + "zoom_in": ("Zoom In", "Navigation"), + "zoom_out": ("Zoom Out", "Navigation"), + "toggle_faces": ("Show Faces", "View"), + "select_all": ("Select All", "Selection"), + "select_none": ("Select None", "Selection"), + "invert_selection": ("Invert Selection", "Selection"), +} + +DEFAULT_GLOBAL_SHORTCUTS = { + # action_name: (key, mods, ignore_if_typing) + "quit_app": (Qt.Key_Q, Qt.ControlModifier, False), + "toggle_visibility": (Qt.Key_H, Qt.ControlModifier, False), + "close_all_viewers": (Qt.Key_Q, Qt.ShiftModifier, False), + "load_more_images": (Qt.Key_D, Qt.ControlModifier, True), + "load_all_images": (Qt.Key_D, Qt.ControlModifier | Qt.ShiftModifier, True), + "save_layout": (Qt.Key_S, Qt.NoModifier, True), + "load_layout": (Qt.Key_L, Qt.NoModifier, True), + "open_folder": (Qt.Key_F, Qt.ControlModifier, True), + "move_to_trash": (Qt.Key_Delete, Qt.NoModifier, True), + "delete_permanently": (Qt.Key_Delete, Qt.ShiftModifier, True), + "rename_image": (Qt.Key_F2, Qt.NoModifier, True), + "refresh_content": (Qt.Key_F5, Qt.NoModifier, True), + "first_image": (Qt.Key_Home, Qt.NoModifier, True), + "last_image": (Qt.Key_End, Qt.NoModifier, True), + "prev_page": (Qt.Key_PageUp, Qt.NoModifier, True), + "next_page": (Qt.Key_PageDown, Qt.NoModifier, True), + "zoom_in": (Qt.Key_Plus, Qt.NoModifier, True), + "zoom_out": (Qt.Key_Minus, Qt.NoModifier, True), + "toggle_faces": (Qt.Key_F7, Qt.NoModifier, True), + "select_all": (Qt.Key_A, Qt.ControlModifier, True), + "select_none": (Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, True), + "invert_selection": (Qt.Key_I, Qt.ControlModifier, True), +} + +VIEWER_ACTIONS = { + "close": ("Close Viewer / Exit Fullscreen", "Window"), + "next": ("Next Image", "Navigation"), + "prev": ("Previous Image", "Navigation"), + "rename": ("Rename Image", "File"), + "toggle_statusbar": ("Toggle Status Bar", "View"), + "toggle_filmstrip": ("Toggle Filmstrip", "View"), + "slideshow": ("Toggle Slideshow", "View"), + "slideshow_reverse": ("Toggle Reverse Slideshow", "View"), + "toggle_faces": ("Show Faces", "View"), + "fullscreen": ("Toggle Fullscreen", "Window"), + "detect_faces": ("Detect Faces", "Actions"), + "detect_pets": ("Detect Pets", "Actions"), + "fast_tag": ("Quick Tags", "Actions"), + "rotate_right": ("Rotate Right", "Transform"), + "rotate_left": ("Rotate Left", "Transform"), + "zoom_in": ("Zoom In", "Transform"), + "zoom_out": ("Zoom Out", "Transform"), + "reset_zoom": ("Reset Zoom (100%)", "Transform"), + "toggle_animation": ("Pause/Resume Animation", "Playback"), + "properties": ("Properties", "File"), + "toggle_visibility": ("Show/Hide Main Window", "Window"), + "toggle_crop": ("Toggle Crop Mode", "Edit"), + "save_crop": ("Save Cropped Image", "File"), +} + +DEFAULT_VIEWER_SHORTCUTS = { + # action_name: (key, mods) + "close": (Qt.Key_Escape, Qt.NoModifier), + "next": (Qt.Key_Space, Qt.NoModifier), + "prev": (Qt.Key_Backspace, Qt.NoModifier), + "rename": (Qt.Key_F2, Qt.NoModifier), + "toggle_statusbar": (Qt.Key_F3, Qt.NoModifier), + "toggle_filmstrip": (Qt.Key_F4, Qt.NoModifier), + "slideshow": (Qt.Key_F6, Qt.NoModifier), + "slideshow_reverse": (Qt.Key_F6, Qt.ShiftModifier), + "toggle_faces": (Qt.Key_F8, Qt.NoModifier), + "fullscreen": (Qt.Key_F11, Qt.NoModifier), + "detect_faces": (Qt.Key_F, Qt.NoModifier), + "detect_pets": (Qt.Key_P, Qt.NoModifier), + "fast_tag": (Qt.Key_T, Qt.NoModifier), + "rotate_right": (Qt.Key_Plus, Qt.ControlModifier), + "rotate_left": (Qt.Key_Minus, Qt.ControlModifier), + "zoom_in": (Qt.Key_Plus, Qt.NoModifier), + "zoom_out": (Qt.Key_Minus, Qt.NoModifier), + "reset_zoom": (Qt.Key_Z, Qt.NoModifier), + "toggle_animation": (Qt.Key_P, Qt.ShiftModifier), + "properties": (Qt.Key_Return, Qt.AltModifier), + "toggle_visibility": (Qt.Key_H, Qt.ControlModifier), + "toggle_crop": (Qt.Key_C, Qt.NoModifier), + "save_crop": (Qt.Key_S, Qt.ControlModifier), +} + + +# --- TEXT CONSTANTS --- + +# Supported languages +SUPPORTED_LANGUAGES = { + "en": "English", + "es": "Español", + "gl": "Galego" +} + +# Default language +DEFAULT_LANGUAGE = "en" +# Determine current language: +# 1. Environment variable (for debugging/override) +# 2. Saved configuration +# 3. Default +CURRENT_LANGUAGE = os.getenv("BAGHEERA_LANG") or \ + APP_CONFIG.get("language", DEFAULT_LANGUAGE) + +# Ensure the loaded language is supported, otherwise fallback to default +if CURRENT_LANGUAGE not in SUPPORTED_LANGUAGES: + CURRENT_LANGUAGE = DEFAULT_LANGUAGE + +_UI_TEXTS = { + "en": { + "READY": "Ready", + "SEARCH": "Search", + "SELECT": "Select", + "ERROR": "Error", + "WARNING": "Warning", + "INFO": "Info", + "LOAD": "Load", + "SAVE": "Save", + "CREATE": "Create", + "RENAME": "Rename", + "COPY": "Copy", + "DELETE": "Delete", + "UNKNOWN": "Unknown", + "MENU_LANGUAGE": "Language", + "RESTART_REQUIRED_TITLE": "Restart Required", + "RESTART_REQUIRED_TEXT": "The language has been changed to {language}.\nPlease " + "restart the application for the changes to take full effect.", + "SORT_NAME_ASC": "Name ↑", + "SORT_NAME_DESC": "Name ↓", + "SORT_DATE_ASC": "Date ↑", + "SORT_DATE_DESC": "Date ↓", + "VIEW_MODE_FLAT": "Flat", + "MENU_VIEW_MODE": "View Mode", + "FILTERED_COUNT": "Filtered: {}", + "VIEW_MODE_DAY": "Separate by Day", + "VIEW_MODE_WEEK": "Separate by Week", + "VIEW_MODE_MONTH": "Separate by Month", + "VIEW_MODE_YEAR": "Separate by Year", + "VIEW_MODE_RATING": "Separate by Rating", + "FILTERED_ZERO": "Filtered: 0", + "VIEW_MODE_FOLDER": "Separate by Folder", + "LOAD_MORE_TOOLTIP": f"Load {APP_CONFIG.get('scan_batch_size', 64)} images " + "more (Ctrl+D)", + "LOAD_ALL_TOOLTIP": "Load all images (Ctrl+Shift+D)", + "LOAD_ALL_TOOLTIP_ALT": "Cancel loading all images (Ctrl+Shift+D)", + "CONFIRM_LOAD_ALL_TITLE": "Confirm load", + "CONFIRM_LOAD_ALL_TEXT": "Are you sure you want to load {} images left?", + "DONE_SCAN": "Done: {} images", + "GROUP_BY_WEEK_FORMAT": "{year} - Week {week}", + "GROUP_HEADER_FORMAT": "{group_name} - {count} images", + "GROUP_HEADER_FORMAT_SINGULAR": "{group_name} - 1 image", + "GROUP_BY_RATING_FORMAT": "{stars} Stars", + "LOADING_SCAN": "Loading... {} / {}", + "SHUTTING_DOWN": "Shutting down...", + "LOADED_PARTIAL": "Loaded {} / {}", + "HIGH_RES_GENERATED": "High-res thumbnails generated.", + "SCANNING_DIRS": "Scanning directories...", + "SELECT_IMAGE_TITLE": "Select Image", + "VIEWER_TITLE_PAUSED": " [Paused]", + "IMAGE_NOT_IN_VIEW": "Image '{}' not in current view.", + "VIEWER_TITLE_SLIDESHOW": " [Slideshow]", + "RENAME_VIEWER_TITLE": "Rename File", + "RENAME_VIEWER_TEXT": "New name for '{}':", + "RENAME_VIEWER_ERROR_EXISTS": "File '{}' already exists.", + "RENAME_VIEWER_ERROR_SYSTEM": "System Error", + "RENAME_VIEWER_ERROR_TEXT": "Could not rename file: {}", + "ADD_FACE_TITLE": "Add Face", + "ADD_PET_TITLE": "Add Pet", + "ADD_OBJECT_TITLE": "Add Object", + "ADD_LANDMARK_TITLE": "Add Landmark", + "ADD_FACE_LABEL": "Name:", + "ADD_PET_LABEL": "Name:", + "ADD_OBJECT_LABEL": "Name:", + "ADD_LANDMARK_LABEL": "Name:", + "DELETE_FACE": "Delete Face or area", + "CREATE_TAG_TITLE": "Create Tag", + "CREATE_TAG_TEXT": "The tag for '{}' does not exist. Do you want to create a " + "new one?", + "NEW_PERSON_TAG_TITLE": "New Person Tag", + "NEW_PERSON_TAG_TEXT": "Enter the full path for the tag:", + "NEW_PET_TAG_TITLE": "New Pet Tag", + "NEW_PET_TAG_TEXT": "Enter the full path for the tag:", + "NEW_OBJECT_TAG_TITLE": "New Object Tag", + "NEW_OBJECT_TAG_TEXT": "Enter the full path for the tag:", + "NEW_LANDMARK_TAG_TITLE": "New Landmark Tag", + "NEW_LANDMARK_TAG_TEXT": "Enter the full path for the tag:", + "SELECT_TAG_TITLE": "Select Tag", + "SELECT_TAG_TEXT": "Multiple tags found for '{}'. Please select the correct " + "one:", + "FACE_NAME_TOOLTIP": "Type a name or select from history.", + "CLEAR_TEXT_TOOLTIP": "Clear text field", + "RENAME_FACE_TITLE": "Rename Face or area", + "SHOW_FACES": "Show Faces && other areas", + "DETECT_FACES": "Detect Face", + "DETECT_PETS": "Detect Pets", + "NO_FACE_LIBS": "No face detection libraries found. Install 'mediapipe' or " + "'face_recognition'.", + "THUMBNAIL_NO_NAME": "No name", + "THUMBNAIL_NO_TAGS": "No tags", + "MENU_ABOUT": "About", + "MENU_ABOUT_TITLE": "About {}", + "MENU_ABOUT_TEXT": "{0} v{1}

A simple image viewer and manager " + "for KDE with Baloo support.

Created by {2} with the help of AI, and " + "mostly the good people at KDE and Qt.", + "MENU_CACHE": "Cache", + "MENU_CLEAR_CACHE": "Clear cache ({} items, {:.1f} MB, {:.1f} MB on disk)", + "MENU_CLEAN_CACHE": "Clean up invalid cache entries", + "MENU_SHOW_TAGS": "Show Tags", + "MENU_SHOW_INFO": "Show Information", + "MENU_SHOW_FILTER": "Show Filter", + "MENU_SHOW_LAYOUTS": "Show Layouts", + "MENU_SHOW_HISTORY": "Show History", + "MENU_SETTINGS": "Settings", + "SETTINGS_GROUP_SCANNER": "Scanner", + "SETTINGS_GROUP_FACES": "Faces && areas", + "SETTINGS_GROUP_THUMBNAILS": "Thumbnails", + "SETTINGS_GROUP_VIEWER": "Image Viewer", + "SETTINGS_PERSON_TAGS_LABEL": "Person tags:", + "SETTINGS_FACE_ENGINE_LABEL": "Face Detection Engine:", + "SETTINGS_FACE_COLOR_LABEL": "Face box color:", + "SETTINGS_MRU_TAGS_COUNT_LABEL": "Max MRU tags:", + "SETTINGS_PET_TAGS_LABEL": "Pet tags:", + "SETTINGS_PET_ENGINE_LABEL": "Pet Detection Engine:", + "SETTINGS_PET_COLOR_LABEL": "Pet box color:", + "SETTINGS_PET_HISTORY_COUNT_LABEL": "Max pet history:", + "SETTINGS_PET_TAGS_TOOLTIP": "Default tags for pets, separated by commas.", + "SETTINGS_PET_ENGINE_TOOLTIP": "Library used for pet detection.", + "SETTINGS_PET_COLOR_TOOLTIP": "Color of the bounding box drawn around " + "detected pets.", + "SETTINGS_PET_HISTORY_TOOLTIP": "Maximum number of recently used pet names " + "to remember.", + "TYPE_FACE": "Face", + "TYPE_PET": "Pet", + "TYPE_OBJECT": "Object", + "TYPE_LANDMARK": "Landmark", + "SETTINGS_OBJECT_TAGS_LABEL": "Object tags:", + "SETTINGS_OBJECT_ENGINE_LABEL": "Object Detection Engine:", + "SETTINGS_OBJECT_COLOR_LABEL": "Object box color:", + "SETTINGS_OBJECT_HISTORY_COUNT_LABEL": "Max object history:", + "SETTINGS_OBJECT_TAGS_TOOLTIP": "Default tags for objects, separated by " + "commas.", + "SETTINGS_OBJECT_ENGINE_TOOLTIP": "Library used for object detection.", + "SETTINGS_OBJECT_COLOR_TOOLTIP": "Color of the bounding box drawn around " + "objects.", + "SETTINGS_OBJECT_HISTORY_TOOLTIP": "Maximum number of recently used object " + "names to remember.", + "SETTINGS_LANDMARK_TAGS_LABEL": "Landmark tags:", + "SETTINGS_LANDMARK_ENGINE_LABEL": "Landmark Detection Engine:", + "SETTINGS_LANDMARK_COLOR_LABEL": "Landmark box color:", + "SETTINGS_LANDMARK_HISTORY_COUNT_LABEL": "Max landmark history:", + "SETTINGS_LANDMARK_TAGS_TOOLTIP": "Default tags for landmarks, separated " + "by commas.", + "SETTINGS_LANDMARK_ENGINE_TOOLTIP": "Library used for landmark detection.", + "SETTINGS_LANDMARK_COLOR_TOOLTIP": "Color of the bounding box drawn around " + "landmarks.", + "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Maximum number of recently used " + "landmark names to remember.", + "SETTINGS_FACE_HISTORY_COUNT_LABEL": "Max face history:", + "SETTINGS_THUMBS_REFRESH_LABEL": "Thumbs refresh interval (ms):", + "MENU_VIEWER_SETTINGS": "Viewer Settings", + "SETTINGS_THUMBS_BG_COLOR_LABEL": "Thumbnails background color:", + "SETTINGS_THUMBS_FILENAME_COLOR_LABEL": "Thumbnails filename color:", + "SETTINGS_THUMBS_TAGS_COLOR_LABEL": "Thumbnails tags color:", + "SETTINGS_THUMBS_RATING_COLOR_LABEL": "Thumbnails rating color:", + "SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Thumbnails filename font size:", + "SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Thumbnails tags font size:", + "SETTINGS_SCAN_MAX_LEVEL_LABEL": "Scan Max Level:", + "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Scan Batch Size:", + "SETTINGS_SCAN_FULL_ON_START_LABEL": "Scan Full On Start:", + "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "File search engine:", + "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Engine to use for finding files. " + "'Native' uses BagheeraSearch library. 'baloosearch' uses KDE Baloo command.", + "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Maximum directory depth to scan " + "recursively.", + "SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Number of images to load in each batch.", + "SETTINGS_SCAN_FULL_ON_START_TOOLTIP": "Automatically scan all images in the " + "folder on startup.", + "SETTINGS_PERSON_TAGS_TOOLTIP": "Default tags for people, separated by commas.", + "SETTINGS_FACE_ENGINE_TOOLTIP": "Library used for face detection (MediaPipe " + "recommended).", + "SETTINGS_FACE_COLOR_TOOLTIP": "Color of the bounding box drawn around " + "detected faces.", + "SETTINGS_MRU_TAGS_TOOLTIP": "Maximum number of recently used tags to " + "remember.", + "SETTINGS_FACE_HISTORY_TOOLTIP": "Maximum number of recently used face names " + "to remember.", + "SETTINGS_THUMBS_REFRESH_TOOLTIP": "Delay in milliseconds before refreshing " + "thumbnails after resizing.", + "SETTINGS_THUMBS_BG_COLOR_TOOLTIP": "Background color of the thumbnail grid " + "view.", + "SETTINGS_THUMBS_FILENAME_COLOR_TOOLTIP": "Font color for filenames in " + "thumbnails.", + "SETTINGS_THUMBS_TAGS_COLOR_TOOLTIP": "Font color for tags in thumbnails.", + "SETTINGS_THUMBS_RATING_COLOR_TOOLTIP": "Color for rating stars in thumbnails.", + "SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Font size for filenames in " + "thumbnails.", + "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Font size for tags in thumbnails.", + "SEARCH_ENGINE_NATIVE": "Native", + "SEARCH_ENGINE_BALOO": "baloosearch", + "SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Viewer mouse wheel speed:", + "SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Filename lines:", + "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Number of lines for the filename " + "text under the thumbnail.", + "SETTINGS_THUMBS_TAGS_LINES_LABEL": "Tag lines:", + "SETTINGS_THUMBS_TOOLTIP_BG_COLOR_LABEL": "Tooltip background color:", + "SETTINGS_THUMBS_TOOLTIP_FG_COLOR_LABEL": "Tooltip text color:", + "SETTINGS_THUMBS_TOOLTIP_FG_COLOR_TOOLTIP": "Text color for tooltips on " + "thumbnails.", + "SETTINGS_THUMBS_TOOLTIP_BG_COLOR_TOOLTIP": "Background color for tooltips on " + "thumbnails.", + "SETTINGS_THUMBS_TAGS_LINES_TOOLTIP": "Number of lines for the tags text under " + "the thumbnail.", + "SETTINGS_THUMBS_SHOW_FILENAME_LABEL": "Show filename", + "SETTINGS_THUMBS_SHOW_RATING_LABEL": "Show rating", + "SETTINGS_THUMBS_SHOW_TAGS_LABEL": "Show tags", + "SETTINGS_THUMBS_SHOW_FILENAME_TOOLTIP": "Show or hide the filename under the " + "thumbnail.", + "SETTINGS_THUMBS_SHOW_RATING_TOOLTIP": "Show or hide the rating stars under " + "the thumbnail.", + "SETTINGS_THUMBS_SHOW_TAGS_TOOLTIP": "Show or hide the tags under the " + "thumbnail.", + "SETTINGS_VIEWER_WHEEL_SPEED_TOOLTIP": "Adjusts how fast scrolling the mouse " + "wheel changes images in the viewer.", + "SETTINGS_VIEWER_AUTO_RESIZE_LABEL": "Auto resize window on zoom", + "SETTINGS_VIEWER_AUTO_RESIZE_TOOLTIP": "Automatically resize the window when " + "zooming or changing images, fitting to the content.", + "SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL": "Download Model", + "SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL_TOOLTIP": "Download the required model file " + "for MediaPipe face detection.", + "MEDIAPIPE_DOWNLOADING_TITLE": "Downloading Model", + "MEDIAPIPE_DOWNLOADING_TEXT": "Downloading MediaPipe face detection model...", + "MEDIAPIPE_DOWNLOAD_SUCCESS_TITLE": "Download Complete", + "MEDIAPIPE_DOWNLOAD_SUCCESS_TEXT": "The MediaPipe model has been downloaded " + "successfully.", + "MEDIAPIPE_DOWNLOAD_ERROR_TITLE": "Download Error", + "MEDIAPIPE_DOWNLOAD_ERROR_TEXT": "Failed to download the MediaPipe model: {}", + "MENU_FILMSTRIP_POSITION": "Filmstrip Position", + "FILMSTRIP_BOTTOM": "Bottom", + "FILMSTRIP_LEFT": "Left", + "FILMSTRIP_TOP": "Top", + "FILMSTRIP_RIGHT": "Right", + "FILMSTRIP_POS_CHANGED_INFO": "The new filmstrip position will be applied to " + "newly opened viewers.", + "MENU_SHOW_SHORTCUTS": "Configure Keyboard Shortcuts...", + "SHORTCUTS_TITLE": "Keyboard Shortcuts", + "SHORTCUTS_ACTION": "Action", + "SHORTCUTS_KEY": "Shortcut", + "CLOSE": "Close", + "SHORTCUT_EDIT_TITLE": "Change Shortcut", + "SHORTCUT_EDIT_LABEL": "Enter new shortcut for '{}'", + "SHORTCUT_CONFLICT_TITLE": "Shortcut Conflict", + "SHORTCUT_CONFLICT_TEXT": "The shortcut '{}' is already assigned to '{}'.", + "SHORTCUT_SEARCH_PLACEHOLDER": "Search shortcuts...", + "CACHE_CLEANING": "Cleaning cache...", + "CACHE_CLEANED": "Cache cleaned. Removed {} invalid entries.", + "CACHE_CLEARED": "Thumbnail cache cleared.", + "ERROR_DELETING_FILE": "Error trying to delete file:\n{}", + "RENAME_FILE_TITLE": "Rename File", + "RENAME_FILE_TEXT": "New name for '{}':", + "RENAME_ERROR_TITLE": "Rename Error", + "RENAME_ERROR_EXISTS": "File '{}' already exists.", + "FILE_RENAMED": "File renamed to {}", + "CONFIRM_CLEAR_CACHE_TITLE": "Confirm Clear Cache", + "CONFIRM_CLEAR_CACHE_TEXT": "Are you sure you want to permanently delete the " + "entire thumbnail cache?", + "CONFIRM_CLEAR_CACHE_INFO": "This will remove all cached thumbnails from " + "memory and disk. They will be regenerated as you browse, which may be slow. " + "This action cannot be undone.", + "CONFIRM_DELETE_TITLE": "Confirm Permanent Deletion", + "CONFIRM_DELETE_TEXT": "Do you want to permanently delete this image?", + "CONFIRM_DELETE_INFO": "File: {}\n\nThis action CANNOT be undone.", + "SYSTEM_ERROR": "System Error", + "ERROR_DELETING_FILE": "Error trying to delete file:\n{}", + "RENAME_FILE_TITLE": "Rename File", + "RENAME_FILE_TEXT": "New name for '{}':", + "RENAME_ERROR_TITLE": "Rename Error", + "RENAME_ERROR_EXISTS": "File '{}' already exists.", + "FILE_RENAMED": "File renamed to {}", + "ERROR_RENAME": "Could not rename file: {}", + "MAIN_DOCK_TITLE": "Main dock", + "LAYOUTS_TAB": "Layouts", + "LAYOUTS_TABLE_HEADER": ["Name", "Last Modified"], + "SAVE_LAYOUT_TITLE": "Save Layout", + "SAVE_LAYOUT_TEXT": "Enter name for layout:", + "LAYOUT_EXISTS_TITLE": "Layout already exists", + "LAYOUT_EXISTS_TEXT": "Do you want to overwrite layout \"{}\"?", + "LAYOUT_EXISTS_INFO": "This action CANNOT be undone.", + "LAYOUT_SAVED": "Layout '{0}' saved.", + "ERROR_SAVING_LAYOUT": "Could not save layout: {}", + "LOAD_LAYOUT_TITLE": "Load Layout", + "NO_LAYOUTS_FOUND": "No saved layouts found.", + "SELECT_LAYOUT": "Select layout:", + "LAYOUT_RESTORED": "Layout restored.", + "ERROR_LOADING_LAYOUT_TITLE": "{}: Error", + "ERROR_LOADING_LAYOUT_TEXT": "Failed to load layout file:\n\"{}\"", + "RENAME_LAYOUT_TITLE": "Rename Layout", + "RENAME_LAYOUT_TEXT": "New Name:", + "COPY_LAYOUT_TITLE": "Copy Layout", + "COPY_LAYOUT_TEXT": "New Name:", + "LAYOUT_ALREADY_EXISTS": "Layout already exists.", + "CONFIRM_DELETE_LAYOUT_TITLE": "Confirm Delete", + "CONFIRM_DELETE_LAYOUT_TEXT": "Delete layout '{}'?", + "INFO_TAB": "Information", + "INFO_RATING_LABEL": "Rating:", + "INFO_COMMENT_LABEL": "Comment:", + "COMMENT_APPLY_CHANGES": "Apply Changes", + "ENTER_COMMENT": "Enter comment...", + "TAGS_TAB": "Tags", + "TAG_FILTER_TAB": "Filter", + "TAG_SEARCH_PLACEHOLDER": "Search tags...", + "TAG_APPLY_CHANGES": "Apply Changes", + "TAG_USED_TAGS": "⭐ USED TAGS", + "TAG_ALL_TAGS": "📂 ALL TAGS", + "TAG_NEW_TAG_TITLE": "New Tag", + "SEARCH_BY_TAG": "Search by this tag", + "TAG_NEW_TAG_TEXT": "Enter tag name (use / for hierarchy):", + "SEARCH_ADD_AND": "Add AND this tag to search", + "SEARCH_ADD_OR": "Add OR this tag to search", + "FILTER_AND": "AND", + "FILTER_OR": "OR", + "FILTER_INVERT": "Invert", + "FILTER_TAG_COLUMN": "Tag", + "FILTER_NOT_COLUMN": "NOT", + "FILTER_STATS_HIDDEN": "{} items hidden", + "FILTER_NAME_PLACEHOLDER": "Filter by filename...", + "HISTORY_TAB": "History", + "HISTORY_TABLE_HEADER": ["Name", "Date"], + "HISTORY_BTN_CLEAR_ALL_TOOLTIP": "Clear All", + "HISTORY_BTN_DELETE_SELECTED_TOOLTIP": "Delete Selected", + "HISTORY_BTN_DELETE_OLDER_TOOLTIP": "Delete Older", + "HISTORY_CLEAR_ALL_TITLE": "Confirm", + "HISTORY_CLEAR_ALL_TEXT": "Clear entire history?", + "PROPERTIES_TITLE": "Properties", + "PROPERTIES_GENERAL_TAB": "General", + "PROPERTIES_METADATA_TAB": "Metadata", + "PROPERTIES_EXIF_TAB": "EXIF", + "PROPERTIES_FILENAME": "File Name:", + "PROPERTIES_LOCATION": "Location:", + "PROPERTIES_SIZE": "Size:", + "PROPERTIES_CREATED": "Created:", + "PROPERTIES_MODIFIED": "Modified:", + "PROPERTIES_DIMENSIONS": "Dimensions:", + "PROPERTIES_FORMAT": "Format:", + "PROPERTIES_MEGAPIXELS": "Megapixels:", + "PROPERTIES_COLOR_DEPTH": "Color Depth:", + "BITS": "bits", + "PROPERTIES_TABLE_HEADER": ["Property", "Value"], + "PROPERTIES_ADD_ATTR": "Add Attribute", + "PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):", + "PROPERTIES_ADD_ATTR_VALUE": "Value for {}:", + "PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}", + "PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}", + "PROPERTIES_DELETE_ATTR": "Delete Attribute", + "PROPERTIES_ERROR_DELETE_ATTR": "Failed to remove xattr: {}", + "EXIV2_NOT_INSTALLED": "exiv2 library not installed. Install python exiv2.", + "NO_METADATA_FOUND": "No metadata found (EXIF/XMP/IPTC).", + "VIEWER_MENU_SLIDESHOW": "Slideshow", + "VIEWER_MENU_STOP_SLIDESHOW": "Stop Slideshow", + "VIEWER_MENU_START_SLIDESHOW": "Start Slideshow", + "VIEWER_MENU_START_REVERSE_SLIDESHOW": "Start Reverse Slideshow", + "VIEWER_MENU_STOP_REVERSE_SLIDESHOW": "Stop Reverse Slideshow", + "VIEWER_MENU_SET_INTERVAL": "Set Interval...", + "VIEWER_MENU_ROTATE": "Rotate", + "VIEWER_MENU_ROTATE_LEFT": "Left", + "VIEWER_MENU_ROTATE_RIGHT": "Right", + "VIEWER_MENU_EXIT_FULLSCREEN": "Exit Fullscreen ", + "VIEWER_MENU_ENTER_FULLSCREEN": "Fullscreen", + "VIEWER_MENU_RENAME": "Rename", + "VIEWER_MENU_FIT_SCREEN": "Fit to Screen / Actual Size", + "VIEWER_MENU_SHOW_FILMSTRIP": "Show Filmstrip", + "VIEWER_MENU_FLIP": "Flip", + "VIEWER_MENU_FLIP_H": "Horizontal", + "VIEWER_MENU_PAUSE_ANIMATION": "Pause Animation", + "VIEWER_MENU_RESUME_ANIMATION": "Resume Animation", + "VIEWER_MENU_FLIP_V": "Vertical", + "VIEWER_MENU_SHOW_STATUSBAR": "Show Status Bar", + "VIEWER_MENU_TAGS": "Quick tags", + "VIEWER_MENU_CROP": "Crop Mode", + "VIEWER_MENU_SAVE_CROP": "Save Selection...", + "SAVE_CROP_TITLE": "Save Cropped Image", + "SAVE_CROP_FILTER": "Images (*.jpg *.jpeg *.png *.bmp *.webp)", + "SLIDESHOW_INTERVAL_TITLE": "Slideshow Interval", + "SLIDESHOW_INTERVAL_TEXT": "Seconds:", + "CONTEXT_MENU_VIEW": "View", + "CONTEXT_MENU_OPEN": "Open", + "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Open and search location", + "CONTEXT_MENU_OPEN_DEFAULT_APP": "Open location with default application", + "CONTEXT_MENU_MOVE_TO": "Move to...", + "CONTEXT_MENU_COPY_TO": "Copy to...", + "CONTEXT_MENU_ROTATE": "Rotate", + "CONTEXT_MENU_ROTATE_LEFT": "Left", + "CONTEXT_MENU_ROTATE_RIGHT": "Right", + "CONTEXT_MENU_TRASH": "Move to Trash", + "CONTEXT_MENU_CLIPBOARD": "Clipboard", + "CONTEXT_MENU_COPY_URL": "Copy File URL", + "CONTEXT_MENU_COPY_DIR": "Copy Directory Path", + "CONTEXT_MENU_PROPERTIES": "Properties", + "CONTEXT_MENU_NO_APPS_FOUND": "No apps found", + "CONTEXT_MENU_REGENERATE": "Regenerate Thumbnail", + "CONTEXT_MENU_ERROR_LISTING_APPS": "Error listing apps", + "CONTEXT_MENU_RENAME": "Rename...", + "CONTEXT_MENU_DELETE": "Delete", + "CONTEXT_MENU_SELECT_ALL": "Select All", + "CONTEXT_MENU_SELECT_NONE": "Select None", + "CONTEXT_MENU_INVERT_SELECTION": "Invert Selection", + "CONFIRM_OVERWRITE_TITLE": "Confirm Overwrite", + "CONFIRM_OVERWRITE_TEXT": "File already exists in destination:\n{}\n\nDo " + "you want to overwrite it?", + "ERROR_MOVE_FILE": "Could not move file: {}", + "ERROR_COPY_FILE": "Could not copy file: {}", + "MOVED_TO": "Moved to {}", + "COPIED_TO": "Copied to {}", + "ERROR_ROTATE_IMAGE": "Could not rotate image: {}", + }, + "es": { + "READY": "Listo", + "SEARCH": "Buscar", + "SELECT": "Seleccionar", + "ERROR": "Error", + "WARNING": "Advertencia", + "INFO": "Información", + "LOAD": "Cargar", + "SAVE": "Guardar", + "CREATE": "Crear", + "RENAME": "Renombrar", + "COPY": "Copiar", + "DELETE": "Eliminar", + "UNKNOWN": "Desconocido", + "MENU_LANGUAGE": "Idioma", + "RESTART_REQUIRED_TITLE": "Reinicio Requerido", + "RESTART_REQUIRED_TEXT": "El idioma se ha cambiado a {language}.\nPor favor, " + "reinicie la aplicación para que los cambios surtan efecto.", + "SORT_NAME_ASC": "Nombre ↑", + "SORT_NAME_DESC": "Nombre ↓", + "SORT_DATE_ASC": "Fecha ↑", + "SORT_DATE_DESC": "Fecha ↓", + "VIEW_MODE_FLAT": "Plano", + "MENU_VIEW_MODE": "Modo de Vista", + "FILTERED_COUNT": "Filtrados: {}", + "VIEW_MODE_DAY": "Separar por Día", + "VIEW_MODE_WEEK": "Separar por Semana", + "VIEW_MODE_MONTH": "Separar por Mes", + "VIEW_MODE_YEAR": "Separar por Año", + "VIEW_MODE_RATING": "Separar por Valoración", + "FILTERED_ZERO": "Filtrados: 0", + "VIEW_MODE_FOLDER": "Separar por Carpeta", + "LOAD_MORE_TOOLTIP": f"Cargar {APP_CONFIG.get('scan_batch_size', 64)} imágenes " + "más (Ctrl+D)", + "LOAD_ALL_TOOLTIP": "Cargar todas las imágenes (Ctrl+Shift+D)", + "LOAD_ALL_TOOLTIP_ALT": "Cancelar cargar todas las images (Ctrl+Shift+D)", + "CONFIRM_LOAD_ALL_TITLE": "Confirmar carga", + "CONFIRM_LOAD_ALL_TEXT": "¿Seguro que quieres cargar las {} imágenes " + "restantes?", + "DONE_SCAN": "Hecho: {} imágenes", + "LOADING_SCAN": "Cargando... {} / {}", + "GROUP_HEADER_FORMAT": "{group_name} - {count} fotos", + "GROUP_HEADER_FORMAT_SINGULAR": "{group_name} - 1 foto", + "GROUP_BY_WEEK_FORMAT": "{year} - Semana {week}", + "GROUP_BY_RATING_FORMAT": "{stars} Estrellas", + "SHUTTING_DOWN": "Cerrando...", + "LOADED_PARTIAL": "Cargadas {} / {}", + "HIGH_RES_GENERATED": "Miniaturas de alta resolución generadas.", + "SCANNING_DIRS": "Escaneando directorios...", + "SELECT_IMAGE_TITLE": "Seleccionar Imagen", + "VIEWER_TITLE_PAUSED": " [Pausado]", + "IMAGE_NOT_IN_VIEW": "La imagen '{}' no está en la vista actual.", + "VIEWER_TITLE_SLIDESHOW": " [Presentación]", + "RENAME_VIEWER_TITLE": "Renombrar Archivo", + "RENAME_VIEWER_TEXT": "Nuevo nombre para '{}':", + "RENAME_VIEWER_ERROR_EXISTS": "El archivo '{}' ya existe.", + "RENAME_VIEWER_ERROR_SYSTEM": "Error de Sistema", + "RENAME_VIEWER_ERROR_TEXT": "No se pudo renombrar el archivo: {}", + "ADD_FACE_TITLE": "Añadir Rostro", + "ADD_PET_TITLE": "Añadir Mascota", + "ADD_OBJECT_TITLE": "Añadir Objeto", + "ADD_LANDMARK_TITLE": "Añadir Lugar", + "ADD_FACE_LABEL": "Nombre:", + "ADD_PET_LABEL": "Nombre:", + "ADD_OBJECT_LABEL": "Nombre:", + "ADD_LANDMARK_LABEL": "Nombre:", + "DELETE_FACE": "Eliminar Rostro o área", + "CREATE_TAG_TITLE": "Crear Etiqueta", + "CREATE_TAG_TEXT": "La etiqueta para '{}' no existe. ¿Deseas crear una nueva?", + "NEW_PERSON_TAG_TITLE": "Nueva Etiqueta de Persona", + "NEW_PERSON_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", + "NEW_PET_TAG_TITLE": "Nueva Etiqueta de Mascota", + "NEW_PET_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", + "NEW_OBJECT_TAG_TITLE": "Nueva Etiqueta de Objeto", + "NEW_OBJECT_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", + "NEW_LANDMARK_TAG_TITLE": "Nueva Etiqueta de Lugar", + "NEW_LANDMARK_TAG_TEXT": "Introduce la ruta completa de la etiqueta:", + "SELECT_TAG_TITLE": "Seleccionar Etiqueta", + "SELECT_TAG_TEXT": "Se encontraron múltiples etiquetas para '{}'. Por favor, " + "selecciona la correcta:", + "FACE_NAME_TOOLTIP": "Escribe un nombre o selecciónalo del historial.", + "CLEAR_TEXT_TOOLTIP": "Limpiar el campo de texto", + "RENAME_FACE_TITLE": "Renombrar Rostro o área", + "SHOW_FACES": "Mostrar Rostros y otras áreas", + "DETECT_FACES": "Detectar Rostros", + "DETECT_PETS": "Detectar Mascotas", + "NO_FACE_LIBS": "No se encontraron librerías de detección de rostros. Instale " + "'mediapipe' o 'face_recognition'.", + "THUMBNAIL_NO_NAME": "Sin nombre", + "THUMBNAIL_NO_TAGS": "Sin etiquetas", + "MENU_ABOUT": "Acerca de", + "MENU_ABOUT_TITLE": "Acerca de {}", + "MENU_ABOUT_TEXT": "{0} v{1}

Un visor y gestor de imágenes " + "simple para KDE con soporte para Baloo.

Creado por {2} con la ayuda de " + "la IA, y mayormente la buena gente de KDE y Qt.", + "MENU_CACHE": "Caché", + "MENU_CLEAR_CACHE": "Limpiar caché ({} ítems, {:.1f} MB, {:.1f} MB en disco)", + "MENU_CLEAN_CACHE": "Limpiar entradas de caché inválidas", + "MENU_SHOW_TAGS": "Mostrar Etiquetas", + "MENU_SHOW_INFO": "Mostrar Información", + "MENU_SHOW_FILTER": "Mostrar Filtro", + "MENU_SHOW_LAYOUTS": "Mostrar Diseños", + "MENU_SHOW_HISTORY": "Mostrar Historial", + "MENU_SETTINGS": "Opciones", + "SETTINGS_GROUP_SCANNER": "Escáner", + "SETTINGS_GROUP_FACES": "Rostros y áreas", + "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", + "SETTINGS_GROUP_VIEWER": "Visor de Imágenes", + "SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persona:", + "SETTINGS_FACE_ENGINE_LABEL": "Motor de detección de caras:", + "SETTINGS_FACE_COLOR_LABEL": "Color del recuadro de cara:", + "SETTINGS_MRU_TAGS_COUNT_LABEL": "Máx etiquetas recientes:", + "SETTINGS_PET_TAGS_LABEL": "Etiquetas de mascota:", + "SETTINGS_PET_ENGINE_LABEL": "Motor de detección de mascotas:", + "SETTINGS_PET_COLOR_LABEL": "Color del recuadro de mascota:", + "SETTINGS_PET_HISTORY_COUNT_LABEL": "Máx historial mascotas:", + "SETTINGS_PET_TAGS_TOOLTIP": "Etiquetas predeterminadas para mascotas, " + "separadas por comas.", + "SETTINGS_PET_ENGINE_TOOLTIP": "Librería utilizada para la detección de " + "mascotas.", + "SETTINGS_PET_COLOR_TOOLTIP": "Color del cuadro delimitador dibujado " + "alrededor de las mascotas detectadas.", + "SETTINGS_PET_HISTORY_TOOLTIP": "Número máximo de nombres de mascotas " + "usados recientemente para recordar.", + "TYPE_FACE": "Cara", + "TYPE_PET": "Mascota", + "TYPE_OBJECT": "Objeto", + "TYPE_LANDMARK": "Lugar", + "SETTINGS_OBJECT_TAGS_LABEL": "Etiquetas de objeto:", + "SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de objetos:", + "SETTINGS_OBJECT_COLOR_LABEL": "Color del recuadro de objeto:", + "SETTINGS_OBJECT_HISTORY_COUNT_LABEL": "Máx historial objetos:", + "SETTINGS_OBJECT_TAGS_TOOLTIP": "Etiquetas predeterminadas para objetos, " + "separadas por comas.", + "SETTINGS_OBJECT_ENGINE_TOOLTIP": "Librería utilizada para la detección " + "de objetos.", + "SETTINGS_OBJECT_COLOR_TOOLTIP": "Color del cuadro delimitador dibujado " + "alrededor de los objetos.", + "SETTINGS_OBJECT_HISTORY_TOOLTIP": "Número máximo de nombres de objetos " + "usados recientemente para recordar.", + "SETTINGS_LANDMARK_TAGS_LABEL": "Etiquetas de lugar:", + "SETTINGS_LANDMARK_ENGINE_LABEL": "Motor de detección de lugares:", + "SETTINGS_LANDMARK_COLOR_LABEL": "Color del recuadro de lugar:", + "SETTINGS_LANDMARK_HISTORY_COUNT_LABEL": "Máx historial lugares:", + "SETTINGS_LANDMARK_TAGS_TOOLTIP": "Etiquetas predeterminadas para " + "lugares/monumentos, separadas por comas.", + "SETTINGS_LANDMARK_ENGINE_TOOLTIP": "Librería utilizada para la detección " + "de lugares.", + "SETTINGS_LANDMARK_COLOR_TOOLTIP": "Color del cuadro delimitador dibujado " + "alrededor de los lugares.", + "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nombres de lugares " + "usados recientemente para recordar.", + "SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máx historial caras:", + "SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):", + "SETTINGS_THUMBS_BG_COLOR_LABEL": "Color de fondo de miniaturas:", + "SETTINGS_THUMBS_FILENAME_COLOR_LABEL": "Color del nombre de fichero:", + "SETTINGS_THUMBS_TAGS_COLOR_LABEL": "Color de etiquetas de miniaturas:", + "SETTINGS_THUMBS_RATING_COLOR_LABEL": "Color de valoración de miniaturas:", + "SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Tamaño de fuente del nombre de " + "fichero:", + "SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Tamaño de fuente de las etiquetas:", + "SETTINGS_SCAN_MAX_LEVEL_LABEL": "Nivel Máximo de Escaneo:", + "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño de Lote de Escaneo:", + "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "Motor de búsqueda de archivos:", + "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar archivos. " + "'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa el commando de" + "KDE Baloo.", + "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo al Inicio:", + "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidad máxima de directorio para " + "escanear recursivamente.", + "SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Número de imágenes a cargar en cada lote.", + "SETTINGS_SCAN_FULL_ON_START_TOOLTIP": "Escanear automáticamente todas las " + "imágenes de la carpeta al inicio.", + "SETTINGS_PERSON_TAGS_TOOLTIP": "Etiquetas predeterminadas para personas, " + "separadas por comas.", + "SETTINGS_FACE_ENGINE_TOOLTIP": "Librería utilizada para la detección de " + "rostros (se recomienda MediaPipe).", + "SETTINGS_FACE_COLOR_TOOLTIP": "Color del cuadro delimitador dibujado " + "alrededor de los rostros detectados.", + "SETTINGS_MRU_TAGS_TOOLTIP": "Número máximo de etiquetas usadas recientemente " + "para recordar.", + "SETTINGS_FACE_HISTORY_TOOLTIP": "Número máximo de nombres de rostros usados " + "recientemente para recordar.", + "SETTINGS_THUMBS_REFRESH_TOOLTIP": "Retraso en milisegundos antes de " + "actualizar las miniaturas tras redimensionar.", + "SETTINGS_THUMBS_BG_COLOR_TOOLTIP": "Color de fondo de la vista de cuadrícula " + "de miniaturas.", + "SETTINGS_THUMBS_FILENAME_COLOR_TOOLTIP": "Color de fuente para nombres de " + "archivo en miniaturas.", + "SETTINGS_THUMBS_TAGS_COLOR_TOOLTIP": "Color de fuente para etiquetas en " + "miniaturas.", + "SETTINGS_THUMBS_RATING_COLOR_TOOLTIP": "Color para las estrellas de " + "valoración en miniaturas.", + "SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Tamaño de fuente para nombres " + "de archivo en miniaturas.", + "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Tamaño de fuente para etiquetas en " + "miniaturas.", + "SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Líneas para nombre de archivo:", + "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Número de líneas para el nombre del " + "archivo debajo de la miniatura.", + "SETTINGS_THUMBS_TAGS_LINES_LABEL": "Líneas para etiquetas:", + "SETTINGS_THUMBS_TOOLTIP_BG_COLOR_LABEL": "Color de fondo del tooltip:", + "SETTINGS_THUMBS_TOOLTIP_FG_COLOR_LABEL": "Color de texto del tooltip:", + "SETTINGS_THUMBS_TOOLTIP_FG_COLOR_TOOLTIP": "Color del texto para los tooltips " + "en las miniaturas.", + "SETTINGS_THUMBS_TOOLTIP_BG_COLOR_TOOLTIP": "Color de fondo para los tooltips " + "en las miniaturas.", + "SETTINGS_THUMBS_TAGS_LINES_TOOLTIP": "Número de líneas para el texto de las " + "etiquetas debajo de la miniatura.", + "SETTINGS_THUMBS_SHOW_FILENAME_LABEL": "Mostrar nombre de archivo", + "SETTINGS_THUMBS_SHOW_RATING_LABEL": "Mostrar valoración", + "SETTINGS_THUMBS_SHOW_TAGS_LABEL": "Mostrar etiquetas", + "SETTINGS_THUMBS_SHOW_FILENAME_TOOLTIP": "Mostrar u ocultar el nombre del " + "archivo debajo de la miniatura.", + "SETTINGS_THUMBS_SHOW_RATING_TOOLTIP": "Mostrar u ocultar las estrellas de " + "valoración debajo de la miniatura.", + "SETTINGS_THUMBS_SHOW_TAGS_TOOLTIP": "Mostrar u ocultar las etiquetas debajo " + "de la miniatura.", + "SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Velocidad de la rueda del ratón en el " + "visor:", + "SETTINGS_VIEWER_AUTO_RESIZE_LABEL": "Redimensionar ventana automáticamente", + "SETTINGS_VIEWER_AUTO_RESIZE_TOOLTIP": "Redimensiona la ventana " + "automáticamente " + "al hacer zoom o cambiar de imagen para ajustarse al contenido.", + "SETTINGS_VIEWER_WHEEL_SPEED_TOOLTIP": "Ajusta la velocidad con la que la " + "rueda del ratón cambia de imagen en el visor.", + "SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL": "Descargar Modelo", + "SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL_TOOLTIP": "Descarga el archivo de modelo " + "necesario para la detección de caras con MediaPipe.", + "MEDIAPIPE_DOWNLOADING_TITLE": "Descargando Modelo", + "MEDIAPIPE_DOWNLOADING_TEXT": "Descargando el modelo de detección de caras de " + "MediaPipe...", + "MEDIAPIPE_DOWNLOAD_SUCCESS_TITLE": "Descarga Completa", + "MEDIAPIPE_DOWNLOAD_SUCCESS_TEXT": "El modelo de MediaPipe se ha descargado " + "correctamente.", + "MEDIAPIPE_DOWNLOAD_ERROR_TITLE": "Error de Descarga", + "MEDIAPIPE_DOWNLOAD_ERROR_TEXT": "Fallo al descargar el modelo de MediaPipe: " + "{}", + "MENU_VIEWER_SETTINGS": "Opciones del Visor", + "MENU_FILMSTRIP_POSITION": "Posición de la Tira de Imágenes", + "FILMSTRIP_BOTTOM": "Abajo", + "FILMSTRIP_LEFT": "Izquierda", + "FILMSTRIP_TOP": "Arriba", + "FILMSTRIP_RIGHT": "Derecha", + "FILMSTRIP_POS_CHANGED_INFO": "La nueva posición de la tira de imágenes se " + "aplicará a los nuevos visores que se abran.", + "MENU_SHOW_SHORTCUTS": "Configurar Atajos de Teclado...", + "SHORTCUTS_TITLE": "Atajos de Teclado", + "SHORTCUTS_ACTION": "Acción", + "SHORTCUTS_KEY": "Atajo", + "CLOSE": "Cerrar", + "SHORTCUT_EDIT_TITLE": "Cambiar Atajo", + "SHORTCUT_EDIT_LABEL": "Nuevo atajo para '{}'", + "SHORTCUT_CONFLICT_TITLE": "Conflicto de Atajos", + "SHORTCUT_CONFLICT_TEXT": "El atajo '{}' ya está asignado a '{}'.", + "SHORTCUT_SEARCH_PLACEHOLDER": "Buscar atajos...", + "CACHE_CLEANING": "Limpiando caché...", + "CACHE_CLEANED": "Caché limpiada. Se eliminaron {} entradas inválidas.", + "CACHE_CLEARED": "Caché de miniaturas limpiada.", + "CONFIRM_CLEAR_CACHE_TITLE": "Confirmar Limpieza de Caché", + "CONFIRM_CLEAR_CACHE_TEXT": "¿Seguro que quieres eliminar permanentemente toda " + "la caché de miniaturas?", + "CONFIRM_CLEAR_CACHE_INFO": "Esto eliminará todas las miniaturas cacheadas de " + "la memoria y el disco. Se regenerarán mientras navegas, lo que puede ser " + "lento. Esta acción no se puede deshacer.", + "CONFIRM_DELETE_TITLE": "Confirmar Borrado Permanente", + "CONFIRM_DELETE_TEXT": "¿Deseas eliminar permanentemente esta imagen?", + "CONFIRM_DELETE_INFO": "Archivo: {}\n\nEsta acción NO se puede deshacer.", + "SYSTEM_ERROR": "Error de Sistema", + "ERROR_DELETING_FILE": "Error al intentar borrar el archivo:\n{}", + "RENAME_FILE_TITLE": "Renombrar Archivo", + "RENAME_FILE_TEXT": "Nuevo nombre para '{}':", + "RENAME_ERROR_TITLE": "Error al Renombrar", + "RENAME_ERROR_EXISTS": "El archivo '{}' ya existe.", + "FILE_RENAMED": "Archivo renombrado a {}", + "ERROR_RENAME": "No se pudo renombrar el archivo: {}", + "MAIN_DOCK_TITLE": "Panel principal", + "LAYOUTS_TAB": "Diseños", + "LAYOUTS_TABLE_HEADER": ["Nombre", "Última Modificación"], + "SAVE_LAYOUT_TITLE": "Guardar Diseño", + "SAVE_LAYOUT_TEXT": "Introduce un nombre para el diseño:", + "LAYOUT_EXISTS_TITLE": "El diseño ya existe", + "LAYOUT_EXISTS_TEXT": "¿Deseas sobreescribir el diseño \"{}\"?", + "LAYOUT_EXISTS_INFO": "Esta acción NO se puede deshacer.", + "LAYOUT_SAVED": "Diseño '{0}' guardado.", + "ERROR_SAVING_LAYOUT": "No se pudo guardar el diseño: {}", + "LOAD_LAYOUT_TITLE": "Cargar Diseño", + "NO_LAYOUTS_FOUND": "No se encontraron diseños guardados.", + "SELECT_LAYOUT": "Seleccionar diseño:", + "LAYOUT_RESTORED": "Diseño restaurado.", + "ERROR_LOADING_LAYOUT_TITLE": "{}: Error", + "ERROR_LOADING_LAYOUT_TEXT": "Fallo al cargar el archivo de diseño:\n\"{}\"", + "RENAME_LAYOUT_TITLE": "Renombrar Diseño", + "RENAME_LAYOUT_TEXT": "Nuevo Nombre:", + "COPY_LAYOUT_TITLE": "Copiar Diseño", + "COPY_LAYOUT_TEXT": "Nuevo Nombre:", + "LAYOUT_ALREADY_EXISTS": "El diseño ya existe.", + "CONFIRM_DELETE_LAYOUT_TITLE": "Confirmar Eliminación", + "CONFIRM_DELETE_LAYOUT_TEXT": "¿Eliminar el diseño '{}'?", + "INFO_TAB": "Información", + "INFO_RATING_LABEL": "Puntuación:", + "INFO_COMMENT_LABEL": "Comentario:", + "COMMENT_APPLY_CHANGES": "Aplicar Cambios", + "ENTER_COMMENT": "Escribe un comentario...", + "TAGS_TAB": "Etiquetas", + "TAG_FILTER_TAB": "Filtro", + "TAG_SEARCH_PLACEHOLDER": "Buscar etiquetas...", + "TAG_APPLY_CHANGES": "Aplicar Cambios", + "TAG_USED_TAGS": "⭐ ETIQUETAS USADAS", + "TAG_ALL_TAGS": "📂 TODAS LAS ETIQUETAS", + "TAG_NEW_TAG_TITLE": "Nueva Etiqueta", + "SEARCH_BY_TAG": "Buscar por esta etiqueta", + "TAG_NEW_TAG_TEXT": "Introduce el nombre de la etiqueta (usa / para " + "jerarquía):", + "SEARCH_ADD_AND": "Añadir AND esta etiqueta a la búsqueda", + "SEARCH_ADD_OR": "Añadir OR esta etiqueta a la búsqueda", + "FILTER_AND": "Y", + "FILTER_OR": "O", + "FILTER_INVERT": "Invertir", + "FILTER_TAG_COLUMN": "Etiqueta", + "FILTER_NOT_COLUMN": "NO", + "FILTER_STATS_HIDDEN": "{} ítems ocultos", + "FILTER_NAME_PLACEHOLDER": "Filtrar por nombre de archivo...", + "HISTORY_TAB": "Historial", + "HISTORY_TABLE_HEADER": ["Nombre", "Fecha"], + "HISTORY_BTN_CLEAR_ALL_TOOLTIP": "Limpiar Todo", + "HISTORY_BTN_DELETE_SELECTED_TOOLTIP": "Eliminar Seleccionados", + "HISTORY_BTN_DELETE_OLDER_TOOLTIP": "Eliminar Antiguos", + "HISTORY_CLEAR_ALL_TITLE": "Confirmar", + "HISTORY_CLEAR_ALL_TEXT": "¿Limpiar todo el historial?", + "PROPERTIES_TITLE": "Propiedades", + "PROPERTIES_GENERAL_TAB": "General", + "PROPERTIES_METADATA_TAB": "Metadatos", + "PROPERTIES_EXIF_TAB": "EXIF", + "PROPERTIES_FILENAME": "Nombre de Archivo:", + "PROPERTIES_LOCATION": "Ubicación:", + "PROPERTIES_SIZE": "Tamaño:", + "PROPERTIES_CREATED": "Creado:", + "PROPERTIES_MODIFIED": "Modificado:", + "PROPERTIES_DIMENSIONS": "Dimensiones:", + "PROPERTIES_FORMAT": "Formato:", + "PROPERTIES_MEGAPIXELS": "Megapíxeles:", + "PROPERTIES_COLOR_DEPTH": "Profundidad de color:", + "BITS": "bits", + "PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"], + "PROPERTIES_ADD_ATTR": "Añadir Atributo", + "PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):", + "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", + "PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}", + "PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}", + "PROPERTIES_DELETE_ATTR": "Eliminar Atributo", + "PROPERTIES_ERROR_DELETE_ATTR": "Fallo al eliminar xattr: {}", + "EXIV2_NOT_INSTALLED": "Librería exiv2 no instalada. Instale python exiv2.", + "NO_METADATA_FOUND": "No se encontraron metadatos (EXIF/XMP/IPTC).", + "VIEWER_MENU_SLIDESHOW": "Presentación", + "VIEWER_MENU_STOP_SLIDESHOW": "Detener Presentación", + "VIEWER_MENU_START_SLIDESHOW": "Iniciar Presentación", + "VIEWER_MENU_START_REVERSE_SLIDESHOW": "Iniciar Presentación Inversa", + "VIEWER_MENU_STOP_REVERSE_SLIDESHOW": "Detener Presentación Inversa", + "VIEWER_MENU_SET_INTERVAL": "Establecer Intervalo...", + "VIEWER_MENU_ROTATE": "Rotar", + "VIEWER_MENU_ROTATE_LEFT": "Izquierda", + "VIEWER_MENU_ROTATE_RIGHT": "Derecha", + "VIEWER_MENU_EXIT_FULLSCREEN": "Salir de Pantalla Completa", + "VIEWER_MENU_ENTER_FULLSCREEN": "Pantalla Completa", + "VIEWER_MENU_RENAME": "Renombrar", + "VIEWER_MENU_FIT_SCREEN": "Ajustar a Pantalla / Tamaño Real", + "VIEWER_MENU_SHOW_FILMSTRIP": "Mostrar Tira de Imágenes", + "VIEWER_MENU_FLIP": "Voltear", + "VIEWER_MENU_FLIP_H": "Horizontal", + "VIEWER_MENU_PAUSE_ANIMATION": "Pausar Animación", + "VIEWER_MENU_RESUME_ANIMATION": "Reanudar Animación", + "VIEWER_MENU_FLIP_V": "Vertical", + "VIEWER_MENU_SHOW_STATUSBAR": "Mostrar Barra de Estado", + "VIEWER_MENU_TAGS": "Etiquetas rápidas", + "VIEWER_MENU_CROP": "Modo Recorte", + "VIEWER_MENU_SAVE_CROP": "Guardar Selección...", + "SAVE_CROP_TITLE": "Guardar Imagen Recortada", + "SAVE_CROP_FILTER": "Imágenes (*.jpg *.jpeg *.png *.bmp *.webp)", + "SLIDESHOW_INTERVAL_TITLE": "Intervalo de Presentación", + "SLIDESHOW_INTERVAL_TEXT": "Segundos:", + "CONTEXT_MENU_VIEW": "Ver", + "CONTEXT_MENU_OPEN": "Abrir", + "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir y buscar en ubicación", + "CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir ubicación con aplicación por defecto", + "CONTEXT_MENU_MOVE_TO": "Mover a...", + "CONTEXT_MENU_COPY_TO": "Copiar a...", + "CONTEXT_MENU_ROTATE": "Girar", + "CONTEXT_MENU_ROTATE_LEFT": "Izquierda", + "CONTEXT_MENU_ROTATE_RIGHT": "Derecha", + "CONTEXT_MENU_TRASH": "Mover a la Papelera", + "CONTEXT_MENU_CLIPBOARD": "Portapapeles", + "CONTEXT_MENU_COPY_URL": "Copiar URL del Archivo", + "CONTEXT_MENU_COPY_DIR": "Copiar Ruta del Directorio", + "CONTEXT_MENU_PROPERTIES": "Propiedades", + "CONTEXT_MENU_NO_APPS_FOUND": "No se encontraron aplicaciones", + "CONTEXT_MENU_REGENERATE": "Regenerar Miniatura", + "CONTEXT_MENU_ERROR_LISTING_APPS": "Error listando aplicaciones", + "CONTEXT_MENU_RENAME": "Renombrar...", + "CONTEXT_MENU_DELETE": "Borrar", + "CONTEXT_MENU_SELECT_ALL": "Seleccionar Todo", + "CONTEXT_MENU_SELECT_NONE": "No Seleccionar Nada", + "CONTEXT_MENU_INVERT_SELECTION": "Invertir Selección", + "CONFIRM_OVERWRITE_TITLE": "Confirmar Sobrescritura", + "CONFIRM_OVERWRITE_TEXT": "El archivo ya existe en el destino:\n{}\n\n¿Deseas " + "sobrescribirlo?", + "ERROR_MOVE_FILE": "No se pudo mover el archivo: {}", + "ERROR_COPY_FILE": "No se pudo copiar el archivo: {}", + "MOVED_TO": "Movido a {}", + "COPIED_TO": "Copiado a {}", + "ERROR_ROTATE_IMAGE": "No se pudo girar la imagen: {}", + }, + "gl": { + "READY": "Listo", + "SEARCH": "Buscar", + "SELECT": "Seleccionar", + "ERROR": "Erro", + "WARNING": "Advertencia", + "INFO": "Información", + "LOAD": "Cargar", + "SAVE": "Gardar", + "CREATE": "Crear", + "RENAME": "Renomear", + "COPY": "Copiar", + "DELETE": "Eliminar", + "UNKNOWN": "Descoñecido", + "MENU_LANGUAGE": "Idioma", + "RESTART_REQUIRED_TITLE": "Requírese Reinicio", + "RESTART_REQUIRED_TEXT": "O idioma cambiouse a {language}.\nPor favor, " + "reinicie a aplicación para que os cambios teñan efecto.", + "SORT_NAME_ASC": "Nome ↑", + "SORT_NAME_DESC": "Nome ↓", + "SORT_DATE_ASC": "Data ↑", + "SORT_DATE_DESC": "Data ↓", + "VIEW_MODE_FLAT": "Plano", + "MENU_VIEW_MODE": "Modo de Vista", + "FILTERED_COUNT": "Filtrados: {}", + "VIEW_MODE_DAY": "Separar por Día", + "VIEW_MODE_WEEK": "Separar por Semana", + "VIEW_MODE_MONTH": "Separar por Mes", + "VIEW_MODE_YEAR": "Separar por Ano", + "VIEW_MODE_RATING": "Separar por Valoración", + "FILTERED_ZERO": "Filtrados: 0", + "VIEW_MODE_FOLDER": "Separar por Cartafol", + "LOAD_MORE_TOOLTIP": f"Cargar {APP_CONFIG.get('scan_batch_size', 64)} imaxes " + "máis (Ctrl+D)", + "LOAD_ALL_TOOLTIP": "Cargar tódalas imaxes (Ctrl+Shift+D)", + "LOAD_ALL_TOOLTIP_ALT": "Cancelar carga de tódalas imaxes (Ctrl+Shift+D)", + "CONFIRM_LOAD_ALL_TITLE": "Confirmar carga", + "CONFIRM_LOAD_ALL_TEXT": "Seguro que queres cargar as {} imaxes " + "restantes?", + "DONE_SCAN": "Feito: {} imaxes", + "LOADING_SCAN": "Cargando... {} / {}", + "GROUP_HEADER_FORMAT": "{group_name} - {count} imaxes", + "GROUP_HEADER_FORMAT_SINGULAR": "{group_name} - 1 imaxe", + "GROUP_BY_WEEK_FORMAT": "{year} - Semana {week}", + "GROUP_BY_RATING_FORMAT": "{stars} Estrelas", + "SHUTTING_DOWN": "Pechando...", + "LOADED_PARTIAL": "Cargadas {} / {}", + "HIGH_RES_GENERATED": "Miniaturas de alta resolución xeradas.", + "SCANNING_DIRS": "Escaneando directorios...", + "SELECT_IMAGE_TITLE": "Seleccionar Imaxe", + "VIEWER_TITLE_PAUSED": " [Pausado]", + "IMAGE_NOT_IN_VIEW": "A imaxe '{}' non está na vista actual.", + "VIEWER_TITLE_SLIDESHOW": " [Presentación]", + "RENAME_VIEWER_TITLE": "Renomear Arquivo", + "RENAME_VIEWER_TEXT": "Novo nome para '{}':", + "RENAME_VIEWER_ERROR_EXISTS": "O ficheiro '{}' xa existe.", + "RENAME_VIEWER_ERROR_SYSTEM": "Erro do Sistema", + "RENAME_VIEWER_ERROR_TEXT": "Non se puido renomear o ficheiro: {}", + "ADD_FACE_TITLE": "Engadir Rostro", + "ADD_PET_TITLE": "Engadir Mascota", + "ADD_OBJECT_TITLE": "Engadir Obxecto", + "ADD_LANDMARK_TITLE": "Engadir Lugar", + "ADD_FACE_LABEL": "Nome:", + "ADD_PET_LABEL": "Nome:", + "ADD_OBJECT_LABEL": "Nome:", + "ADD_LANDMARK_LABEL": "Nome:", + "DELETE_FACE": "Eliminar Rostro ou área", + "CREATE_TAG_TITLE": "Crear Etiqueta", + "CREATE_TAG_TEXT": "A etiqueta para '{}' non existe. Desexas crear unha nova?", + "NEW_PERSON_TAG_TITLE": "Nova Etiqueta de Persoa", + "NEW_PERSON_TAG_TEXT": "Introduce a ruta completa da etiqueta:", + "NEW_PET_TAG_TITLE": "Nova Etiqueta de Mascota", + "NEW_PET_TAG_TEXT": "Introduce a ruta completa da etiqueta:", + "NEW_OBJECT_TAG_TITLE": "Nova Etiqueta de Obxecto", + "NEW_OBJECT_TAG_TEXT": "Introduce a ruta completa da etiqueta:", + "NEW_LANDMARK_TAG_TITLE": "Nova Etiqueta de Lugar", + "NEW_LANDMARK_TAG_TEXT": "Introduce a ruta completa da etiqueta:", + "SELECT_TAG_TITLE": "Seleccionar Etiqueta", + "SELECT_TAG_TEXT": "Atopáronse varias etiquetas para '{}'. Por favor, " + "selecciona a correcta:", + "FACE_NAME_TOOLTIP": "Escribe un nome ou selecciónao do historial.", + "CLEAR_TEXT_TOOLTIP": "Limpar o campo de texto", + "RENAME_FACE_TITLE": "Renomear Rostro ou área", + "SHOW_FACES": "Amosar Rostros e outras áreas", + "DETECT_FACES": "Detectar Rostros", + "DETECT_PETS": "Detectar Mascotas", + "NO_FACE_LIBS": "Non se atoparon librarías de detección de rostros. Instale " + "'mediapipe' ou 'face_recognition'.", + "THUMBNAIL_NO_NAME": "Sen nome", + "THUMBNAIL_NO_TAGS": "Sen etiquetas", + "MENU_ABOUT": "Acerca de", + "MENU_ABOUT_TITLE": "Acerca de {}", + "MENU_ABOUT_TEXT": "{0} v{1}

Un visor e xestor de imaxes " + "sinxelo para KDE con soporte para Baloo.

Creado por {2} coa axuda da " + " IA, e maiormente a boa xente de KDE e Qt.", + "MENU_CACHE": "Caché", + "MENU_CLEAR_CACHE": "Limpar caché ({} elementos, {:.1f} MB, {:.1f} MB en " + "disco)", + "MENU_CLEAN_CACHE": "Limpar entradas de caché inválidas", + "MENU_SHOW_TAGS": "Amosar Etiquetas", + "MENU_SHOW_INFO": "Amosar Información", + "MENU_SHOW_FILTER": "Amosar Filtro", + "MENU_SHOW_LAYOUTS": "Amosar Deseños", + "MENU_SHOW_HISTORY": "Amosar Historial", + "MENU_SETTINGS": "Opcións", + "SETTINGS_GROUP_SCANNER": "Escáner", + "SETTINGS_GROUP_FACES": "Rostros e áreas", + "SETTINGS_GROUP_THUMBNAILS": "Miniaturas", + "SETTINGS_GROUP_VIEWER": "Visor de Imaxes", + "SETTINGS_PERSON_TAGS_LABEL": "Etiquetas de persoa:", + "SETTINGS_FACE_ENGINE_LABEL": "Motor de detección de caras:", + "SETTINGS_FACE_COLOR_LABEL": "Cor do cadro de cara:", + "SETTINGS_MRU_TAGS_COUNT_LABEL": "Máx etiquetas recentes:", + "SETTINGS_PET_TAGS_LABEL": "Etiquetas de mascota:", + "SETTINGS_PET_ENGINE_LABEL": "Motor de detección de mascotas:", + "SETTINGS_PET_COLOR_LABEL": "Cor do cadro de mascota:", + "SETTINGS_PET_HISTORY_COUNT_LABEL": "Máx historial mascotas:", + "SETTINGS_PET_TAGS_TOOLTIP": "Etiquetas predeterminadas para mascotas, " + "separadas por comas.", + "SETTINGS_PET_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " + "mascotas.", + "SETTINGS_PET_COLOR_TOOLTIP": "Cor do cadro delimitador debuxado arredor " + "das mascotas detectadas.", + "SETTINGS_PET_HISTORY_TOOLTIP": "Número máximo de nomes de mascotas usados " + "recentemente para lembrar.", + "TYPE_FACE": "Cara", + "TYPE_PET": "Mascota", + "TYPE_OBJECT": "Obxecto", + "TYPE_LANDMARK": "Lugar", + "SETTINGS_OBJECT_TAGS_LABEL": "Etiquetas de obxecto:", + "SETTINGS_OBJECT_ENGINE_LABEL": "Motor de detección de obxectos:", + "SETTINGS_OBJECT_COLOR_LABEL": "Cor do cadro de obxecto:", + "SETTINGS_OBJECT_HISTORY_COUNT_LABEL": "Máx historial obxectos:", + "SETTINGS_OBJECT_TAGS_TOOLTIP": "Etiquetas predeterminadas para obxectos, " + "separadas por comas.", + "SETTINGS_OBJECT_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " + "obxectos.", + "SETTINGS_OBJECT_COLOR_TOOLTIP": "Cor do cadro delimitador debuxado arredor " + "dos obxectos.", + "SETTINGS_OBJECT_HISTORY_TOOLTIP": "Número máximo de nomes de obxectos " + "usados recentemente para lembrar.", + "SETTINGS_LANDMARK_TAGS_LABEL": "Etiquetas de lugar:", + "SETTINGS_LANDMARK_ENGINE_LABEL": "Motor de detección de lugares:", + "SETTINGS_LANDMARK_COLOR_LABEL": "Cor do cadro de lugar:", + "SETTINGS_LANDMARK_HISTORY_COUNT_LABEL": "Máx historial lugares:", + "SETTINGS_LANDMARK_TAGS_TOOLTIP": "Etiquetas predeterminadas para " + "lugares/monumentos, separadas por comas.", + "SETTINGS_LANDMARK_ENGINE_TOOLTIP": "Libraría utilizada para a detección " + "de lugares.", + "SETTINGS_LANDMARK_COLOR_TOOLTIP": "Cor do cadro delimitador debuxado " + "arredor dos lugares.", + "SETTINGS_LANDMARK_HISTORY_TOOLTIP": "Número máximo de nomes de lugares " + "usados recentemente para lembrar.", + "SETTINGS_FACE_HISTORY_COUNT_LABEL": "Máx historial caras:", + "SETTINGS_THUMBS_REFRESH_LABEL": "Intervalo refresco miniaturas (ms):", + "SETTINGS_THUMBS_BG_COLOR_LABEL": "Cor de fondo de miniaturas:", + "SETTINGS_THUMBS_FILENAME_COLOR_LABEL": "Cor do nome de ficheiro:", + "SETTINGS_THUMBS_TAGS_COLOR_LABEL": "Cor das etiquetas das miniaturas:", + "SETTINGS_THUMBS_RATING_COLOR_LABEL": "Cor da valoración das miniaturas:", + "SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Tamaño da fonte do nome de " + "ficheiro:", + "SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Tamaño da fonte das etiquetas:", + "SETTINGS_SCAN_THREADS_LABEL": "Hilos de generación:", + "SETTINGS_SCAN_THREADS_TOOLTIP": "Número máximo de hilos simultáneos para " + "generar miniaturas.", + "SETTINGS_SCAN_MAX_LEVEL_LABEL": "Nivel Máximo de Escaneo:", + "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Tamaño do Lote de Escaneo:", + "SETTINGS_SCANNER_SEARCH_ENGINE_LABEL": "Motor de busca de ficheiros:", + "SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP": "Motor a usar para buscar ficheiros. " + "'Nativo' usa la librería de BagheeraSearch. 'baloosearch' usa o comando de " + "KDE Baloo.", + "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo ao Inicio:", + "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidade máxima de directorio para " + "escanear recursivamente.", + "SETTINGS_SCAN_BATCH_SIZE_TOOLTIP": "Número de imaxes a cargar en cada lote.", + "SETTINGS_SCAN_FULL_ON_START_TOOLTIP": "Escanear automaticamente todas as " + "imaxes do cartafol ao inicio.", + "SETTINGS_PERSON_TAGS_TOOLTIP": "Etiquetas predeterminadas para persoas, " + "separadas por comas.", + "SETTINGS_FACE_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " + "rostros (recoméndase MediaPipe).", + "SETTINGS_FACE_COLOR_TOOLTIP": "Cor do cadro delimitador debuxado arredor dos " + "rostros detectados.", + "SETTINGS_MRU_TAGS_TOOLTIP": "Número máximo de etiquetas usadas recentemente " + "para lembrar.", + "SETTINGS_FACE_HISTORY_TOOLTIP": "Número máximo de nomes de rostros usados " + "recentemente para lembrar.", + "SETTINGS_THUMBS_REFRESH_TOOLTIP": "Atraso en milisegundos antes de actualizar " + "as miniaturas tras redimensionar.", + "SETTINGS_THUMBS_BG_COLOR_TOOLTIP": "Cor de fondo da vista de grade de " + "miniaturas.", + "SETTINGS_THUMBS_FILENAME_COLOR_TOOLTIP": "Cor de fonte para nomes de ficheiro " + "en miniaturas.", + "SETTINGS_THUMBS_TAGS_COLOR_TOOLTIP": "Cor de fonte para etiquetas en " + "miniaturas.", + "SETTINGS_THUMBS_RATING_COLOR_TOOLTIP": "Cor para as estrelas de valoración en " + "miniaturas.", + "SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP": "Tamaño de fonte para nomes de " + "ficheiro en miniaturas.", + "SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP": "Tamaño de fonte para etiquetas en " + "miniaturas.", + "SEARCH_ENGINE_NATIVE": "Nativo", + "SEARCH_ENGINE_BALOO": "baloosearch", + "SETTINGS_THUMBS_FILENAME_LINES_LABEL": "Liñas para nome de ficheiro:", + "SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP": "Número de liñas para o nome do " + "ficheiro debaixo da miniatura.", + "SETTINGS_THUMBS_TAGS_LINES_LABEL": "Liñas para etiquetas:", + "SETTINGS_THUMBS_TOOLTIP_BG_COLOR_LABEL": "Cor de fondo do tooltip:", + "SETTINGS_THUMBS_TOOLTIP_FG_COLOR_LABEL": "Cor do texto do tooltip:", + "SETTINGS_THUMBS_TOOLTIP_FG_COLOR_TOOLTIP": "Cor do texto para os tooltips " + "nas miniaturas.", + "SETTINGS_THUMBS_TOOLTIP_BG_COLOR_TOOLTIP": "Cor de fondo para os tooltips " + "nas miniaturas.", + "SETTINGS_THUMBS_TAGS_LINES_TOOLTIP": "Número de liñas para o texto das " + "etiquetas debaixo da miniatura.", + "SETTINGS_THUMBS_SHOW_FILENAME_LABEL": "Amosar nome de ficheiro", + "SETTINGS_THUMBS_SHOW_RATING_LABEL": "Amosar valoración", + "SETTINGS_THUMBS_SHOW_TAGS_LABEL": "Amosar etiquetas", + "SETTINGS_THUMBS_SHOW_FILENAME_TOOLTIP": "Amosar ou ocultar o nome do ficheiro " + "debaixo da miniatura.", + "SETTINGS_THUMBS_SHOW_RATING_TOOLTIP": "Amosar ou ocultar as estrelas de " + "valoración debaixo da miniatura.", + "SETTINGS_THUMBS_SHOW_TAGS_TOOLTIP": "Amosar ou ocultar as etiquetas debaixo " + "da miniatura.", + "SETTINGS_VIEWER_WHEEL_SPEED_LABEL": "Velocidade da roda do rato no visor:", + "SETTINGS_VIEWER_AUTO_RESIZE_LABEL": "Redimensionar xanela automaticamente", + "SETTINGS_VIEWER_AUTO_RESIZE_TOOLTIP": "Redimensiona a xanela automaticamente " + "ao facer zoom ou cambiar de imaxe para axustarse ao contido.", + "SETTINGS_VIEWER_WHEEL_SPEED_TOOLTIP": "Axusta a velocidade coa que a roda do " + "rato cambia de imaxe no visor.", + "SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL": "Descargar Modelo", + "SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL_TOOLTIP": "Descarga o ficheiro de modelo " + "necesario para a detección de caras con MediaPipe.", + "MEDIAPIPE_DOWNLOADING_TITLE": "Descargando Modelo", + "MEDIAPIPE_DOWNLOADING_TEXT": "Descargando o modelo de detección de caras de " + "MediaPipe...", + "MEDIAPIPE_DOWNLOAD_SUCCESS_TITLE": "Descarga Completa", + "MEDIAPIPE_DOWNLOAD_SUCCESS_TEXT": "O modelo de MediaPipe descargouse " + "correctamente.", + "MEDIAPIPE_DOWNLOAD_ERROR_TITLE": "Erro de Descarga", + "MEDIAPIPE_DOWNLOAD_ERROR_TEXT": "Fallo ao descargar o modelo de MediaPipe: {}", + "MENU_VIEWER_SETTINGS": "Opcións do Visor", + "MENU_FILMSTRIP_POSITION": "Posición da Tira de Imaxes", + "FILMSTRIP_BOTTOM": "Abaixo", + "FILMSTRIP_LEFT": "Esquerda", + "FILMSTRIP_TOP": "Arriba", + "FILMSTRIP_RIGHT": "Dereita", + "FILMSTRIP_POS_CHANGED_INFO": "A nova posición da tira de imaxes aplicarase " + "aos novos visores que se abran.", + "MENU_SHOW_SHORTCUTS": "Configurar Atallos de Teclado...", + "SHORTCUTS_TITLE": "Atallos de Teclado", + "SHORTCUTS_ACTION": "Acción", + "SHORTCUTS_KEY": "Atallo", + "CLOSE": "Pechar", + "SHORTCUT_EDIT_TITLE": "Cambiar Atallo", + "SHORTCUT_EDIT_LABEL": "Novo Atallo para '{}'", + "SHORTCUT_CONFLICT_TITLE": "Conflito de Atallos", + "SHORTCUT_CONFLICT_TEXT": "O atallo '{}' xa está asignado a '{}'.", + "SHORTCUT_SEARCH_PLACEHOLDER": "Buscar atallos...", + "CACHE_CLEANING": "Limpando caché...", + "CACHE_CLEANED": "Caché limpada. Elimináronse {} entradas inválidas.", + "CACHE_CLEARED": "Caché de miniaturas limpada.", + "CONFIRM_CLEAR_CACHE_TITLE": "Confirmar Limpeza de Caché", + "CONFIRM_CLEAR_CACHE_TEXT": "Seguro que queres eliminar permanentemente toda " + "a caché de miniaturas?", + "CONFIRM_CLEAR_CACHE_INFO": "Isto eliminará todas as miniaturas da caché da " + "memoria e do disco. Rexeneraranse mentres navegas, o que pode ser " + "lento. Esta acción non se pode desfacer.", + "CONFIRM_DELETE_TITLE": "Confirmar Borrado Permanente", + "CONFIRM_DELETE_TEXT": "Desexas eliminar permanentemente esta imaxe?", + "CONFIRM_DELETE_INFO": "Ficheiro: {}\n\nEsta acción NON se pode desfacer.", + "SYSTEM_ERROR": "Erro do Sistema", + "ERROR_DELETING_FILE": "Erro ao intentar borrar o ficheiro:\n{}", + "RENAME_FILE_TITLE": "Renomear Ficheiro", + "RENAME_FILE_TEXT": "Novo nome para '{}':", + "RENAME_ERROR_TITLE": "Erro ao renomear", + "RENAME_ERROR_EXISTS": "O ficheiro '{}' xa existe.", + "FILE_RENAMED": "Ficheiro renomeado a {}", + "ERROR_RENAME": "Non se puido renomear o ficheiro: {}", + "MAIN_DOCK_TITLE": "Panel principal", + "LAYOUTS_TAB": "Deseños", + "LAYOUTS_TABLE_HEADER": ["Nome", "Última Modificación"], + "SAVE_LAYOUT_TITLE": "Gardar Deseño", + "SAVE_LAYOUT_TEXT": "Introduce un nome para o deseño:", + "LAYOUT_EXISTS_TITLE": "O deseño xa existe", + "LAYOUT_EXISTS_TEXT": "Desexas sobrescribir o deseño \"{}\"?", + "LAYOUT_EXISTS_INFO": "Esta acción NON se pode desfacer.", + "LAYOUT_SAVED": "Deseño '{0}' gardado.", + "ERROR_SAVING_LAYOUT": "Non se puido gardar o deseño: {}", + "LOAD_LAYOUT_TITLE": "Cargar Deseño", + "NO_LAYOUTS_FOUND": "Non se atoparon deseños gardados.", + "SELECT_LAYOUT": "Seleccionar deseño:", + "LAYOUT_RESTORED": "Deseño restaurado.", + "ERROR_LOADING_LAYOUT_TITLE": "{}: Erro", + "ERROR_LOADING_LAYOUT_TEXT": "Fallo ao cargar o ficheiro de deseño:\n\"{}\"", + "RENAME_LAYOUT_TITLE": "Renomear Deseño", + "RENAME_LAYOUT_TEXT": "Novo Nome:", + "COPY_LAYOUT_TITLE": "Copiar Deseño", + "COPY_LAYOUT_TEXT": "Novo Nome:", + "LAYOUT_ALREADY_EXISTS": "O deseño xa existe.", + "CONFIRM_DELETE_LAYOUT_TITLE": "Confirmar Eliminación", + "CONFIRM_DELETE_LAYOUT_TEXT": "Eliminar o deseño '{}'?", + "INFO_TAB": "Información", + "INFO_RATING_LABEL": "Puntuación:", + "INFO_COMMENT_LABEL": "Comentario:", + "COMMENT_APPLY_CHANGES": "Aplicar Cambios", + "ENTER_COMMENT": "Escribe un comentario...", + "TAGS_TAB": "Etiquetas", + "TAG_FILTER_TAB": "Filtro", + "TAG_SEARCH_PLACEHOLDER": "Buscar etiquetas...", + "TAG_APPLY_CHANGES": "Aplicar Cambios", + "TAG_USED_TAGS": "⭐ ETIQUETAS USADAS", + "TAG_ALL_TAGS": "📂 TÓDALAS ETIQUETAS", + "TAG_NEW_TAG_TITLE": "Nova Etiqueta", + "SEARCH_BY_TAG": "Buscar por esta etiqueta", + "TAG_NEW_TAG_TEXT": "Introduce o nome da etiqueta (usa / para " + "xerarquía):", + "SEARCH_ADD_AND": "Engadir AND esta etiqueta á busca", + "SEARCH_ADD_OR": "Engadir OR esta etiqueta á busca", + "FILTER_AND": "E", + "FILTER_OR": "OU", + "FILTER_INVERT": "Inverter", + "FILTER_TAG_COLUMN": "Etiqueta", + "FILTER_NOT_COLUMN": "NON", + "FILTER_STATS_HIDDEN": "{} elementos ocultos", + "FILTER_NAME_PLACEHOLDER": "Filtrar por nome de ficheiro...", + "HISTORY_TAB": "Historial", + "HISTORY_TABLE_HEADER": ["Nome", "Data"], + "HISTORY_BTN_CLEAR_ALL_TOOLTIP": "Limpiar Todo", + "HISTORY_BTN_DELETE_SELECTED_TOOLTIP": "Eliminar Seleccionados", + "HISTORY_BTN_DELETE_OLDER_TOOLTIP": "Eliminar Antiguos", + "HISTORY_CLEAR_ALL_TITLE": "Confirmar", + "HISTORY_CLEAR_ALL_TEXT": "Limpar todo o historial?", + "PROPERTIES_TITLE": "Propiedades", + "PROPERTIES_GENERAL_TAB": "Xeral", + "PROPERTIES_METADATA_TAB": "Metadatos", + "PROPERTIES_EXIF_TAB": "EXIF", + "PROPERTIES_FILENAME": "Nome do Ficheiro:", + "PROPERTIES_LOCATION": "Localización:", + "PROPERTIES_SIZE": "Tamaño:", + "PROPERTIES_CREATED": "Creado:", + "PROPERTIES_MODIFIED": "Modificado:", + "PROPERTIES_DIMENSIONS": "Dimensións:", + "PROPERTIES_FORMAT": "Formato:", + "PROPERTIES_MEGAPIXELS": "Megapíxeles:", + "PROPERTIES_COLOR_DEPTH": "Profundidade da cor:", + "BITS": "bits", + "PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"], + "PROPERTIES_ADD_ATTR": "Engadir Atributo", + "PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):", + "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", + "PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}", + "PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir xattr: {}", + "PROPERTIES_DELETE_ATTR": "Eliminar Atributo", + "PROPERTIES_ERROR_DELETE_ATTR": "Fallo ao eliminar xattr: {}", + "EXIV2_NOT_INSTALLED": "Libraría exiv2 non instalada. Instale python-exiv2.", + "NO_METADATA_FOUND": "Non se atoparon metadatos (EXIF/XMP/IPTC).", + "VIEWER_MENU_SLIDESHOW": "Presentación", + "VIEWER_MENU_STOP_SLIDESHOW": "Deter Presentación", + "VIEWER_MENU_START_SLIDESHOW": "Iniciar Presentación", + "VIEWER_MENU_START_REVERSE_SLIDESHOW": "Iniciar Presentación Inversa", + "VIEWER_MENU_STOP_REVERSE_SLIDESHOW": "Deter Presentación Inversa", + "VIEWER_MENU_SET_INTERVAL": "Establecer Intervalo...", + "VIEWER_MENU_ROTATE": "Xirar", + "VIEWER_MENU_ROTATE_LEFT": "Esquerda", + "VIEWER_MENU_ROTATE_RIGHT": "Dereita", + "VIEWER_MENU_EXIT_FULLSCREEN": "Saír de Pantalla Completa", + "VIEWER_MENU_ENTER_FULLSCREEN": "Pantalla Completa", + "VIEWER_MENU_RENAME": "Renomear", + "VIEWER_MENU_FIT_SCREEN": "Axustar á Pantalla / Tamaño Real", + "VIEWER_MENU_SHOW_FILMSTRIP": "Amosar Tira de Imaxes", + "VIEWER_MENU_FLIP": "Voltear", + "VIEWER_MENU_FLIP_H": "Horizontal", + "VIEWER_MENU_PAUSE_ANIMATION": "Pausar Animación", + "VIEWER_MENU_RESUME_ANIMATION": "Reanudar Animación", + "VIEWER_MENU_FLIP_V": "Vertical", + "VIEWER_MENU_SHOW_STATUSBAR": "Amosar Barra de Estado", + "VIEWER_MENU_TAGS": "Etiquetas rápidas", + "VIEWER_MENU_CROP": "Modo Recorte", + "VIEWER_MENU_SAVE_CROP": "Gardar Selección...", + "SAVE_CROP_TITLE": "Gardar Imaxe Recortada", + "SAVE_CROP_FILTER": "Imaxes (*.jpg *.jpeg *.png *.bmp *.webp)", + "SLIDESHOW_INTERVAL_TITLE": "Intervalo da Presentación", + "SLIDESHOW_INTERVAL_TEXT": "Segundos:", + "CONTEXT_MENU_VIEW": "Ver", + "CONTEXT_MENU_OPEN": "Abrir", + "CONTEXT_MENU_OPEN_SEARCH_LOCATION": "Abrir e buscar na localización", + "CONTEXT_MENU_OPEN_DEFAULT_APP": "Abrir localización coa aplicación por " + "defecto", + "CONTEXT_MENU_MOVE_TO": "Mover a...", + "CONTEXT_MENU_COPY_TO": "Copiar a...", + "CONTEXT_MENU_ROTATE": "Xirar", + "CONTEXT_MENU_ROTATE_LEFT": "Esquerda", + "CONTEXT_MENU_ROTATE_RIGHT": "Dereita", + "CONTEXT_MENU_TRASH": "Mover á Papeleira", + "CONTEXT_MENU_CLIPBOARD": "Portapapeis", + "CONTEXT_MENU_COPY_URL": "Copiar URL do Ficheiro", + "CONTEXT_MENU_COPY_DIR": "Copiar Ruta do Directorio", + "CONTEXT_MENU_PROPERTIES": "Propiedades", + "CONTEXT_MENU_NO_APPS_FOUND": "Non se atoparon aplicacións", + "CONTEXT_MENU_REGENERATE": "Rexenerar Miniatura", + "CONTEXT_MENU_ERROR_LISTING_APPS": "Erro listando aplicacións", + "CONTEXT_MENU_RENAME": "Renomear...", + "CONTEXT_MENU_DELETE": "Borrar", + "CONTEXT_MENU_SELECT_ALL": "Seleccionar Todo", + "CONTEXT_MENU_SELECT_NONE": "Non Seleccionar Nada", + "CONTEXT_MENU_INVERT_SELECTION": "Inverter Selección", + "CONFIRM_OVERWRITE_TITLE": "Confirmar Sobrescritura", + "CONFIRM_OVERWRITE_TEXT": "O ficheiro xa existe no destino:\n{}\n\nDesexas " + "sobrescribilo?", + "ERROR_MOVE_FILE": "Non se puido mover o ficheiro: {}", + "ERROR_COPY_FILE": "Non se puido copiar o ficheiro: {}", + "MOVED_TO": "Movido a {}", + "COPIED_TO": "Copiado a {}", + "ERROR_ROTATE_IMAGE": "Non se puido xirar a imaxe: {}", + } +} + + +class _UITextsProxy: + """ + A proxy class to access UI strings from the _UI_TEXTS dictionary. + + This allows using `UITexts.SOME_STRING` syntax, which dynamically fetches + the string for the CURRENT_LANGUAGE, with a fallback to the DEFAULT_LANGUAGE. + This makes the rest of the application code independent of the language management + logic. + """ + def __getattr__(self, name): + # Get the dictionary for the current language, or fallback to the default. + lang_texts = _UI_TEXTS.get(CURRENT_LANGUAGE, _UI_TEXTS[DEFAULT_LANGUAGE]) + # Get the specific string. If not found in the current language, + # try the default language. + text = lang_texts.get(name) + if text is None: + default_texts = _UI_TEXTS[DEFAULT_LANGUAGE] + # Return a placeholder if not found anywhere + text = default_texts.get(name, f"_{name}_") + return text + + +# Create a single instance to be used throughout the application. +UITexts = _UITextsProxy() diff --git a/imagecontroller.py b/imagecontroller.py new file mode 100644 index 0000000..27a4e6f --- /dev/null +++ b/imagecontroller.py @@ -0,0 +1,757 @@ +""" +Image Controller Module for Bagheera. + +This module provides the core logic for managing image state, including navigation, +loading, transformations (zoom, rotation), and look-ahead preloading for a smooth +user experience. + +Classes: + ImagePreloader: A QThread worker that loads the next image in the background. + ImageController: A QObject that manages the image list, current state, and + interacts with the ImagePreloader. +""" +import os +import math +from PySide6.QtCore import QThread, Signal, QMutex, QWaitCondition, QObject, Qt +from PySide6.QtGui import QImage, QImageReader, QPixmap, QTransform +from xmpmanager import XmpManager +from constants import ( + APP_CONFIG, AVAILABLE_FACE_ENGINES, AVAILABLE_PET_ENGINES, + MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, MEDIAPIPE_OBJECT_MODEL_PATH, + MEDIAPIPE_OBJECT_MODEL_URL, RATING_XATTR_NAME, XATTR_NAME, UITexts +) +from metadatamanager import XattrManager + + +class ImagePreloader(QThread): + """ + A worker thread to preload the next image in the sequence. + + This class runs in the background to load an image before it is needed, + reducing perceived loading times during navigation. + + Signals: + image_ready(int, str, QImage): Emitted when an image has been successfully + preloaded, providing its index, path, and the QImage. + """ + image_ready = Signal(int, str, QImage, list, int) # Now emits tags and rating + + def __init__(self): + """Initializes the preloader thread.""" + super().__init__() + self.path = None + self.index = -1 + self.mutex = QMutex() + self.condition = QWaitCondition() + self._stop_flag = False + self.current_processing_path = None + + def request_load(self, path, index): + """ + Requests the thread to load a specific image. + + Args: + path (str): The file path of the image to load. + index (int): The index of the image in the main list. + """ + self.mutex.lock() + if self.current_processing_path == path: + self.path = None + self.mutex.unlock() + return + + if self.path == path: + self.index = index + self.mutex.unlock() + return + + self.path = path + self.index = index + self.condition.wakeOne() + self.mutex.unlock() + + def stop(self): + """Stops the worker thread gracefully.""" + self.mutex.lock() + self._stop_flag = True + self.condition.wakeOne() + self.mutex.unlock() + self.wait() + + def _load_metadata(self, path): + """Loads tag and rating data for a path.""" + tags = [] + raw_tags = XattrManager.get_attribute(path, XATTR_NAME) + if raw_tags: + tags = sorted(list(set(t.strip() + for t in raw_tags.split(',') if t.strip()))) + + raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0") + try: + rating = int(raw_rating) + except ValueError: + rating = 0 + return tags, rating + + def run(self): + """ + The main execution loop for the thread. + + Waits for a load request, reads the image file, and emits the + `image_ready` signal upon success. + """ + while True: + self.mutex.lock() + self.current_processing_path = None + while self.path is None and not self._stop_flag: + self.condition.wait(self.mutex) + + if self._stop_flag: + self.mutex.unlock() + return + + path = self.path + idx = self.index + self.path = None + self.current_processing_path = path + self.mutex.unlock() + + # Ensure file exists before trying to read + if path and os.path.exists(path): + try: + reader = QImageReader(path) + reader.setAutoTransform(True) + img = reader.read() + if not img.isNull(): + # Load tags and rating here to avoid re-reading in main thread + tags, rating = self._load_metadata(path) + self.image_ready.emit(idx, path, img, tags, rating) + except Exception: + pass + + +class ImageController(QObject): + """ + Manages image list navigation, state, and loading logic. + + This controller is the central point for handling the currently displayed + image. It manages the list of images, the current index, zoom/rotation/flip + state, and uses an `ImagePreloader` to implement a look-ahead cache for + the next image to provide a smoother user experience. + """ + metadata_changed = Signal(str, dict) + list_updated = Signal(int) + + def __init__(self, image_list, current_index, initial_tags=None, initial_rating=0): + """ + Initializes the ImageController. + """ + super().__init__() + self.image_list = image_list + self.index = current_index + self.zoom_factor = 1.0 + self.rotation = 0 + self.flip_h = False + self.flip_v = False + self.pixmap_original = QPixmap() + self.faces = [] + self._current_tags = initial_tags if initial_tags is not None else [] + self._current_rating = initial_rating + self.show_faces = False + + # Preloading + self.preloader = ImagePreloader() + self.preloader.image_ready.connect(self._handle_preloaded_image) + self.preloader.start() + self._cached_next_image = None + self._cached_next_index = -1 + + def cleanup(self): + """Stops the background preloader thread.""" + self.preloader.stop() + + def _trigger_preload(self): + """Identifies the next image in the list and asks the preloader to load it.""" + if not self.image_list: + return + next_idx = (self.index + 1) % len(self.image_list) + if next_idx == self.index: + return + + if next_idx != self._cached_next_index: + self.preloader.request_load(self.image_list[next_idx], next_idx) + + def _handle_preloaded_image(self, index, path, image, tags, rating): + """Slot to receive and cache the image and its metadata from the preloader. + + Args: + index (int): The index of the preloaded image. + path (str): The file path of the preloaded image. + image (QImage): The preloaded image data. + tags (list): Preloaded tags for the image. + rating (int): Preloaded rating for the image. + """ + # The signal now emits (index, path, QImage, tags, rating) + # Verify if the loaded path still corresponds to the next index + if self.image_list: + next_idx = (self.index + 1) % len(self.image_list) + if self.image_list[next_idx] == path: + self._cached_next_index = next_idx + self._cached_next_image = image + + # Store preloaded metadata + self._cached_next_tags = tags + self._cached_next_rating = rating + + def get_current_path(self): + """ + Gets the file path of the current image. + + Returns: + str or None: The path of the current image, or None if the list is empty. + """ + if 0 <= self.index < len(self.image_list): + return self.image_list[self.index] + return None + + def load_image(self): + """ + Loads the current image into the controller's main pixmap. + """ + path = self.get_current_path() + self.pixmap_original = QPixmap() + self.rotation = 0 + self.flip_h = False + self._current_tags = [] + self._current_rating = 0 + self.flip_v = False + self.faces = [] + + if not path: + return False + + # Check cache + if self.index == self._cached_next_index and self._cached_next_image: + self.pixmap_original = QPixmap.fromImage(self._cached_next_image) + # Clear cache to free memory as we have consumed the image + self._current_tags = self._cached_next_tags + self._current_rating = self._cached_next_rating + self._cached_next_image = None + self._cached_next_index = -1 + self._cached_next_tags = None + self._cached_next_rating = None + else: + reader = QImageReader(path) # This is a disk read + reader.setAutoTransform(True) + image = reader.read() + if image.isNull(): + self._trigger_preload() + return False + self.pixmap_original = QPixmap.fromImage(image) + + # Load tags and rating if not from cache + self._current_tags, self._current_rating = self._load_metadata(path) + + self.load_faces() + self._trigger_preload() + return True + + def load_faces(self): + """ + Loads face regions from XMP metadata and resolves short names to full + tag paths. + """ + path = self.get_current_path() + faces_from_xmp = XmpManager.load_faces(path) + + if not faces_from_xmp: + self.faces = [] + return + + resolved_faces = [] + seen_faces = set() + + for face in faces_from_xmp: + # Validate geometry to discard malformed regions + if not self._clamp_and_validate_face(face): + continue + + # Check for exact duplicates based on geometry and name + face_sig = (face.get('x'), face.get('y'), face.get('w'), + face.get('h'), face.get('name')) + if face_sig in seen_faces: + continue + seen_faces.add(face_sig) + + short_name = face.get('name', '') + # If name is a short name (no slash) and we have tags on the image + if short_name and '/' not in short_name and self._current_tags: + # Find all full tags on the image that match this short name + possible_matches = [ + tag for tag in self._current_tags + if tag.split('/')[-1] == short_name + ] + + if len(possible_matches) >= 1: + # If multiple matches, pick the first. This is an ambiguity, + # but it's the best we can do. e.g. if image has both + # 'Person/Joe' and 'Friends/Joe' and face is named 'Joe'. + face['name'] = possible_matches[0] + + resolved_faces.append(face) + + self.faces = resolved_faces + + def save_faces(self): + """ + Saves the current faces list to XMP metadata, storing only the short name. + """ + path = self.get_current_path() + if not path: + return + + # Create a temporary list of faces with short names for saving to XMP + faces_to_save = [] + seen_faces = set() + + for face in self.faces: + face_copy = face.copy() + # If the name is a hierarchical tag, save only the last part + if 'name' in face_copy and face_copy['name']: + face_copy['name'] = face_copy['name'].split('/')[-1] + + # Deduplicate to prevent file bloat + face_sig = ( + face_copy.get('x'), face_copy.get('y'), + face_copy.get('w'), face_copy.get('h'), + face_copy.get('name') + ) + if face_sig in seen_faces: + continue + seen_faces.add(face_sig) + + faces_to_save.append(face_copy) + + XmpManager.save_faces(path, faces_to_save) + + def add_face(self, name, x, y, w, h, region_type="Face"): + """Adds a new face. The full tag path should be passed as 'name'.""" + new_face = { + 'name': name, # Expecting full tag path + 'x': x, 'y': y, 'w': w, 'h': h, + 'type': region_type + } + validated_face = self._clamp_and_validate_face(new_face) + if validated_face: + self.faces.append(validated_face) + self.save_faces() + + def remove_face(self, face): + """Removes a face and saves metadata.""" + if face in self.faces: + self.faces.remove(face) + self.save_faces() + + def toggle_tag(self, tag_name, add_tag): + """Adds or removes a tag from the current image's xattrs.""" + current_path = self.get_current_path() + if not current_path: + return + + tags_set = set(self._current_tags) + + tag_changed = False + if add_tag and tag_name not in tags_set: + tags_set.add(tag_name) + tag_changed = True + elif not add_tag and tag_name in tags_set: + tags_set.remove(tag_name) + tag_changed = True + + if tag_changed: + new_tags_list = sorted(list(tags_set)) + new_tags_str = ",".join(new_tags_list) if new_tags_list else None + try: + XattrManager.set_attribute(current_path, XATTR_NAME, new_tags_str) + self._current_tags = new_tags_list # Update internal state + self.metadata_changed.emit(current_path, + {'tags': new_tags_list, + 'rating': self._current_rating}) + except IOError as e: + print(f"Error setting tags for {current_path}: {e}") + + def set_rating(self, new_rating): + current_path = self.get_current_path() + if not current_path: + return + try: + XattrManager.set_attribute(current_path, RATING_XATTR_NAME, str(new_rating)) + self._current_rating = new_rating # Update internal state + self.metadata_changed.emit(current_path, + {'tags': self._current_tags, + 'rating': new_rating}) + except IOError as e: + print(f"Error setting tags for {current_path}: {e}") + + def _clamp_and_validate_face(self, face_data): + """ + Clamps face coordinates to be within the [0, 1] range and ensures validity. + Returns a validated face dictionary or None if invalid. + """ + x = face_data.get('x', 0.5) + y = face_data.get('y', 0.5) + w = face_data.get('w', 0.0) + h = face_data.get('h', 0.0) + + # Ensure all values are finite numbers to prevent propagation of NaN/Inf + if not all(math.isfinite(val) for val in (x, y, w, h)): + return None + + # Basic validation: width and height must be positive + if w <= 0 or h <= 0: + return None + + # Clamp width and height to be at most 1.0 + w = min(w, 1.0) + h = min(h, 1.0) + + # Clamp center coordinates to ensure the box is fully within the image + face_data['x'] = max(w / 2.0, min(x, 1.0 - w / 2.0)) + face_data['y'] = max(h / 2.0, min(y, 1.0 - h / 2.0)) + face_data['w'] = w + face_data['h'] = h + return face_data + + def _detect_faces_face_recognition(self, path): + """Detects faces using the 'face_recognition' library.""" + import face_recognition + new_faces = [] + try: + image = face_recognition.load_image_file(path) + face_locations = face_recognition.face_locations(image) + h, w, _ = image.shape + for (top, right, bottom, left) in face_locations: + box_w = right - left + box_h = bottom - top + new_face = { + 'name': '', + 'x': (left + box_w / 2) / w, 'y': (top + box_h / 2) / h, + 'w': box_w / w, 'h': box_h / h, 'type': 'Face' + } + validated_face = self._clamp_and_validate_face(new_face) + if validated_face: + new_faces.append(validated_face) + except Exception as e: + print(f"Error during face_recognition detection: {e}") + return new_faces + + def _detect_faces_mediapipe(self, path): + """Detects faces using the 'mediapipe' library with the new Tasks API.""" + import mediapipe as mp + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + + new_faces = [] + + if not os.path.exists(MEDIAPIPE_FACE_MODEL_PATH): + print(f"MediaPipe model not found at: {MEDIAPIPE_FACE_MODEL_PATH}") + print("Please download 'blaze_face_short_range.tflite' and place it there.") + print(f"URL: {MEDIAPIPE_FACE_MODEL_URL}") + return new_faces + + try: + base_options = python.BaseOptions( + model_asset_path=MEDIAPIPE_FACE_MODEL_PATH) + options = vision.FaceDetectorOptions(base_options=base_options, + min_detection_confidence=0.5) + + # Silence MediaPipe warnings (stderr) during initialization + stderr_fd = 2 + null_fd = os.open(os.devnull, os.O_WRONLY) + save_fd = os.dup(stderr_fd) + try: + os.dup2(null_fd, stderr_fd) + detector = vision.FaceDetector.create_from_options(options) + finally: + os.dup2(save_fd, stderr_fd) + os.close(null_fd) + os.close(save_fd) + + mp_image = mp.Image.create_from_file(path) + detection_result = detector.detect(mp_image) + + if detection_result.detections: + img_h, img_w = mp_image.height, mp_image.width + for detection in detection_result.detections: + bbox = detection.bounding_box # This is in pixels + new_face = { + 'name': '', + 'x': (bbox.origin_x + bbox.width / 2) / img_w, + 'y': (bbox.origin_y + bbox.height / 2) / img_h, + 'w': bbox.width / img_w, + 'h': bbox.height / img_h, + 'type': 'Face' + } + validated_face = self._clamp_and_validate_face(new_face) + if validated_face: + new_faces.append(validated_face) + + except Exception as e: + print(f"Error during MediaPipe detection: {e}") + return new_faces + + def _detect_pets_mediapipe(self, path): + """Detects pets using the 'mediapipe' library object detection.""" + import mediapipe as mp + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + + new_pets = [] + + if not os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH): + print(f"MediaPipe model not found at: {MEDIAPIPE_OBJECT_MODEL_PATH}") + print("Please download 'efficientdet_lite0.tflite' and place it there.") + print(f"URL: {MEDIAPIPE_OBJECT_MODEL_URL}") + return new_pets + + try: + base_options = python.BaseOptions( + model_asset_path=MEDIAPIPE_OBJECT_MODEL_PATH) + options = vision.ObjectDetectorOptions( + base_options=base_options, + score_threshold=0.5, + max_results=5, + category_allowlist=["cat", "dog"]) # Detect cats and dogs + + # Silence MediaPipe warnings (stderr) during initialization + stderr_fd = 2 + null_fd = os.open(os.devnull, os.O_WRONLY) + save_fd = os.dup(stderr_fd) + try: + os.dup2(null_fd, stderr_fd) + detector = vision.ObjectDetector.create_from_options(options) + finally: + os.dup2(save_fd, stderr_fd) + os.close(null_fd) + os.close(save_fd) + + mp_image = mp.Image.create_from_file(path) + detection_result = detector.detect(mp_image) + + if detection_result.detections: + img_h, img_w = mp_image.height, mp_image.width + for detection in detection_result.detections: + bbox = detection.bounding_box + new_pet = { + 'name': '', + 'x': (bbox.origin_x + bbox.width / 2) / img_w, + 'y': (bbox.origin_y + bbox.height / 2) / img_h, + 'w': bbox.width / img_w, + 'h': bbox.height / img_h, + 'type': 'Pet' + } + validated_pet = self._clamp_and_validate_face(new_pet) + if validated_pet: + new_pets.append(validated_pet) + + except Exception as e: + print(f"Error during MediaPipe pet detection: {e}") + return new_pets + + def detect_faces(self): + """ + Detects faces using a configured or available detection engine. + + The detection order is determined by the user's configuration and + library availability, with a fallback mechanism. + """ + path = self.get_current_path() + if not path: + return [] + + if not AVAILABLE_FACE_ENGINES: + print(UITexts.NO_FACE_LIBS) + return [] + + preferred_engine = APP_CONFIG.get("face_detection_engine") + + # Create an ordered list of engines to try, starting with the preferred one. + engines_to_try = [] + if preferred_engine in AVAILABLE_FACE_ENGINES: + engines_to_try.append(preferred_engine) + # Add other available engines as fallbacks. + for engine in AVAILABLE_FACE_ENGINES: + if engine not in engines_to_try: + engines_to_try.append(engine) + + all_faces = [] + for engine in engines_to_try: + if engine == "mediapipe": + all_faces = self._detect_faces_mediapipe(path) + elif engine == "face_recognition": + all_faces = self._detect_faces_face_recognition(path) + + if all_faces: + break # Stop after the first successful detection. + + return all_faces + + def detect_pets(self): + """ + Detects pets using a configured or available detection engine. + """ + path = self.get_current_path() + if not path: + return [] + + if not AVAILABLE_PET_ENGINES: + print("No pet detection libraries found.") + return [] + + engine = APP_CONFIG.get("pet_detection_engine", "mediapipe") + + if engine == "mediapipe": + return self._detect_pets_mediapipe(path) + + return [] + + def get_display_pixmap(self): + """ + Applies current transformations (rotation, zoom, flip) to the original + pixmap. + + Returns: + QPixmap: The transformed pixmap ready for display. + """ + if self.pixmap_original.isNull(): + return QPixmap() + + transform = QTransform().rotate(self.rotation) + transformed_pixmap = self.pixmap_original.transformed( + transform, + Qt.SmoothTransformation + ) + new_size = transformed_pixmap.size() * self.zoom_factor + scaled_pixmap = transformed_pixmap.scaled(new_size, Qt.KeepAspectRatio, + Qt.SmoothTransformation) + + if self.flip_h: + scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(-1, 1)) + if self.flip_v: + scaled_pixmap = scaled_pixmap.transformed(QTransform().scale(1, -1)) + + return scaled_pixmap + + def rotate(self, angle): + """ + Adds to the current rotation angle. + + Args: + angle (int): The angle in degrees to add (e.g., 90 or -90). + """ + self.rotation += angle + + def toggle_flip_h(self): + """Toggles the horizontal flip state of the image.""" + self.flip_h = not self.flip_h + + def toggle_flip_v(self): + """Toggles the vertical flip state of the image.""" + self.flip_v = not self.flip_v + + def first(self): + """Navigates to the first image in the list.""" + if not self.image_list: + return + self.index = 0 + + def last(self): + """Navigates to the last image in the list.""" + if not self.image_list: + return + self.index = max(0, len(self.image_list) - 1) + + def next(self): + """Navigates to the next image, wrapping around if at the end.""" + if not self.image_list: + return + self.index = (self.index + 1) % len(self.image_list) + + def prev(self): + """Navigates to the previous image, wrapping around if at the beginning.""" + if not self.image_list: + return + self.index = (self.index - 1) % len(self.image_list) + + def update_list(self, new_list, new_index=None, current_image_tags=None, + current_image_rating=0): + """ + Updates the internal image list and optionally the current index. + + This method is used to refresh the list of images the controller works + with, for example, after a filter is applied in the main window. + + Args: + new_list (list): The new list of image paths. + new_index (int, optional): The new index to set. If None, the + controller tries to maintain the current + index, adjusting if it's out of bounds. + Defaults to None. + """ + self.image_list = new_list + if new_index is not None: + self.index = new_index + + if not self.image_list: + self.index = -1 + elif self.index >= len(self.image_list): + self.index = max(0, len(self.image_list) - 1) + elif self.index < 0: + self.index = 0 + + # Update current image metadata if provided + self._current_tags = current_image_tags \ + if current_image_tags is not None else [] + self._current_rating = current_image_rating + self._cached_next_image = None + self._cached_next_index = -1 + self._trigger_preload() + self.list_updated.emit(self.index) + + def _load_metadata(self, path): + """Loads tag and rating data for a path.""" + tags = [] + raw_tags = XattrManager.get_attribute(path, XATTR_NAME) + if raw_tags: + tags = sorted(list(set(t.strip() + for t in raw_tags.split(',') if t.strip()))) + + raw_rating = XattrManager.get_attribute(path, RATING_XATTR_NAME, "0") + try: + rating = int(raw_rating) + except ValueError: + rating = 0 + return tags, rating + + def update_list_on_exists(self, new_list, new_index=None): + """ + Updates the list only if the old list is a subset of the new one. + + This is a specialized update method used to prevent jarring navigation + changes. For instance, when a single image is opened directly, the initial + list contains only that image. When the rest of the directory is scanned + in the background, this method ensures the list is only updated if the + original image is still present, making the transition seamless. + """ + if set(self.image_list) <= set(new_list): + self.image_list = new_list + if new_index is not None: + self.index = new_index + if self.index >= len(self.image_list): + self.index = max(0, len(self.image_list) - 1) + self._current_tags = [] # Clear current tags/rating, will be reloaded + self._current_rating = 0 + self._cached_next_image = None + self._cached_next_index = -1 + self._trigger_preload() + self.list_updated.emit(self.index) diff --git a/imagescanner.py b/imagescanner.py new file mode 100644 index 0000000..97f05cc --- /dev/null +++ b/imagescanner.py @@ -0,0 +1,1635 @@ +""" +Image Scanner Module for Bagheera Image Viewer. + +This module handles asynchronous image discovery, thumbnail generation, and +persistent caching. It ensures the main application UI remains responsive by +offloading file I/O and image processing to background threads. + +Key Features: + - Asynchronous Scanning: Directory traversal and image loading in separate threads. + - Two-Tier Caching: + - In-Memory: LRU-based cache with safe concurrent access. + - Disk: Persistent storage using LMDB (Lightning Memory-Mapped Database). + - Incremental Loading: Batched image processing for memory efficiency. + - Cache Maintenance: Automatic cleanup of invalid entries and size enforcement. + - Improved Synchronization: Context managers, proper mutex handling, no deadlocks. + - Better Path Handling: Uses pathlib for robust path operations. + - Enhanced Error Handling: Detailed logging and recovery mechanisms. +""" +import os +import logging +import shlex +import shutil +import struct +import subprocess +import time +import argparse +import collections +from pathlib import Path +from contextlib import contextmanager +import lmdb +from PySide6.QtCore import (QObject, QThread, Signal, QMutex, QReadWriteLock, QSize, + QWaitCondition, QByteArray, QBuffer, QIODevice, Qt, QTimer, + QRunnable, QThreadPool) +from PySide6.QtGui import QImage, QImageReader, QImageIOHandler + +from constants import ( + APP_CONFIG, CACHE_PATH, CACHE_MAX_SIZE, CONFIG_DIR, DISK_CACHE_MAX_BYTES, + IMAGE_EXTENSIONS, SEARCH_CMD, THUMBNAIL_SIZES, RATING_XATTR_NAME, XATTR_NAME, + UITexts, SCANNER_SETTINGS_DEFAULTS +) + +from imageviewer import ImageViewer +from metadatamanager import XattrManager + +try: + # Attempt to import bagheerasearch for direct integration + from bagheera_search_lib import BagheeraSearcher + HAVE_BAGHEERASEARCH_LIB = True +except ImportError: + HAVE_BAGHEERASEARCH_LIB = False + +# Set up logging for better debugging +logger = logging.getLogger(__name__) + + +def generate_thumbnail(path, size): + """Generates a QImage thumbnail for a given path and size.""" + try: + reader = QImageReader(path) + + # Optimization: Instruct the image decoder to scale while reading. + # This drastically reduces memory usage and CPU time for large images + # (e.g. JPEG). + if reader.supportsOption(QImageIOHandler.ImageOption.ScaledSize): + orig_size = reader.size() + if orig_size.isValid() \ + and (orig_size.width() > size or orig_size.height() > size): + target_size = QSize(orig_size) + target_size.scale(size, size, Qt.KeepAspectRatio) + reader.setScaledSize(target_size) + + reader.setAutoTransform(True) + img = reader.read() + + # Fallback: If optimization failed (and it wasn't just a missing file), + # try standard read + if img.isNull(): + error = reader.error() + # Optimize: Don't retry for AppleDouble files (._*) if UnknownError + if os.path.basename(path).startswith("._") and \ + error == QImageReader.ImageReaderError.UnknownError: + return None + + if error != QImageReader.ImageReaderError.FileNotFoundError: + reader = QImageReader(path) + reader.setAutoTransform(True) + img = reader.read() + + if img.isNull(): + return None + # Always scale to the target size. Qt.KeepAspectRatio will handle + # both upscaling and downscaling correctly. SmoothTransformation gives + # better quality for upscaling. + return img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) + except Exception as e: + logger.error(f"Error generating thumbnail for {path}: {e}") + return None + + +class CacheWriter(QThread): + """ + Dedicated background thread for writing thumbnails to LMDB asynchronously. + Batches writes to improve disk throughput and avoid blocking the scanner. + """ + def __init__(self, cache): + super().__init__() + self.cache = cache + # Use deque for flexible buffer management and deduplication + self._queue = collections.deque() + self._mutex = QMutex() + self._condition_new_data = QWaitCondition() + self._condition_space_available = QWaitCondition() + # Soft limit for blocking producers (background threads) + self._max_size = 50 + self._running = True + + def enqueue(self, item, block=False): + """Queue an item for writing. Item: (device_id, inode_key, img, mtime, size, + path)""" + if not self._running: + return + + self._mutex.lock() + try: + if block: + # Backpressure for background threads (Scanner/Generator) + while len(self._queue) >= self._max_size and self._running: + self._condition_space_available.wait(self._mutex) + + if not self._running: + return + + # --- Soft Cleaning: Deduplication --- + # Remove redundant pending updates for the same image/size (e.g. + # rapid rotations) + if len(item) >= 5: + new_dev, new_inode, _, _, new_size = item[:5] + if self._queue: + # Rebuild deque excluding any item that matches the new one's key + self._queue = collections.deque( + q for q in self._queue + if not (len(q) >= 5 and + q[0] == new_dev and + q[1] == new_inode and + q[4] == new_size) + ) + + # Always append the new item (it becomes the authoritative version) + self._queue.append(item) + self._condition_new_data.wakeOne() + finally: + self._mutex.unlock() + + def stop(self): + self._mutex.lock() + self._running = False + self._queue.clear() + self._condition_new_data.wakeAll() + self._condition_space_available.wakeAll() + self._mutex.unlock() + self.wait() + + def run(self): + self.setPriority(QThread.IdlePriority) + while self._running or self._queue: + self._mutex.lock() + if not self._queue: + if not self._running: + self._mutex.unlock() + break + # Wait for new items + self._condition_new_data.wait(self._mutex) + + # Auto-flush: if queue has data but not enough for a full batch, + # wait up to 200ms for more data. If timeout, flush what we have. + # Only wait if running (flush immediately on stop) + if self._running and self._queue and len(self._queue) < 50: + signaled = self._condition_new_data.wait(self._mutex, 200) + if signaled and self._running and len(self._queue) < 50: + self._mutex.unlock() + continue + + if not self._queue: + self._mutex.unlock() + continue + + # Gather a batch of items + # Adaptive batch size: if queue is backing up, increase transaction size + # to improve throughput. + if not self._running: + # Flush everything if stopping + batch_limit = len(self._queue) + else: + batch_limit = self._max_size + + batch = [] + while self._queue and len(batch) < batch_limit: + batch.append(self._queue.popleft()) + + # Notify producers that space might be available + self._condition_space_available.wakeAll() + self._mutex.unlock() + + if batch: + try: + self.cache._batch_write_to_lmdb(batch) + except Exception as e: + logger.error(f"CacheWriter batch write error: {e}") + + +class CacheLoader(QThread): + """ + Background thread to load and decode thumbnails from LMDB without blocking UI. + Uses LIFO queue (deque) to prioritize most recently requested images. + Implements a "drop-oldest" policy when full to ensure new requests (visible images) + are prioritized over old pending ones (scrolled away). + """ + def __init__(self, cache): + super().__init__() + self.cache = cache + # Use deque for LIFO behavior with drop-oldest capability + self._queue = collections.deque() + self._max_size = 50 + self._mutex = QMutex() + self._condition = QWaitCondition() + self._running = True + + def enqueue(self, item): + if not self._running: + return False, None + + dropped_item = None + self._mutex.lock() + try: + # If queue is full, drop the OLDEST item (left) to make room for NEWEST + # (right). + # This ensures that during fast scrolling, we process what is currently + # on screen (just added) rather than what was on screen moments ago. + while len(self._queue) >= self._max_size: + # Drop oldest and return it so caller can cleanup state + dropped = self._queue.popleft() + dropped_item = (dropped[0], dropped[1]) # path, size + + self._queue.append(item) + self._condition.wakeOne() + return True, dropped_item + finally: + self._mutex.unlock() + + def promote(self, path, size): + """Moves an item to the front of the line (end of deque) if exists.""" + if not self._running: + return + + self._mutex.lock() + try: + # Find item by path and size + for item in self._queue: + if item[0] == path and item[1] == size: + # Move to right end (LIFO pop side - highest priority) + self._queue.remove(item) + self._queue.append(item) + break + finally: + self._mutex.unlock() + + def stop(self): + self._running = False + self._mutex.lock() + self._condition.wakeAll() + self._mutex.unlock() + self.wait() + + def run(self): + self.setPriority(QThread.IdlePriority) + while self._running: + self._mutex.lock() + if not self._queue: + # Wait up to 100ms for new items + self._condition.wait(self._mutex, 100) + + if not self._queue: + self._mutex.unlock() + continue + + # LIFO: Pop from right (newest) + item = self._queue.pop() + self._mutex.unlock() + + path, size, mtime, inode, dev = item + + # Call synchronous get_thumbnail to fetch and decode + # This puts the result into the RAM cache + img, _ = self.cache.get_thumbnail( + path, size, curr_mtime=mtime, inode=inode, + device_id=dev, async_load=False + ) + + if img: + self.cache.thumbnail_loaded.emit(path, size) + + self.cache._mark_load_complete(path, size) + + +class GenerationFuture: + """Helper class to synchronize threads waiting for a thumbnail.""" + def __init__(self): + self._mutex = QMutex() + self._condition = QWaitCondition() + self._finished = False + + def wait(self): + self._mutex.lock() + while not self._finished: + self._condition.wait(self._mutex) + self._mutex.unlock() + + def complete(self): + self._mutex.lock() + self._finished = True + self._condition.wakeAll() + self._mutex.unlock() + + +class ThumbnailCache(QObject): + """ + Thread-safe in-memory thumbnail cache with LMDB disk persistence. + + Optimization: Uses (device, inode) as LMDB key instead of file paths. + This gives: + - Smaller keys (16 bytes vs variable path length) + - Faster lookups and LMDB operations + - Automatic handling of file moves/renames + - Better cache efficiency + """ + + thumbnail_loaded = Signal(str, int) # path, size + + def __init__(self): + super().__init__() + # In-memory cache: {path: {size: (QImage, mtime)}} + self._thumbnail_cache = {} + # Path -> (device, inode) mapping for fast lookup + self._path_to_inode = {} + self._loading_set = set() # Track pending async loads (path, size) + self._futures = {} # Map (dev, inode, size) -> GenerationFuture + self._futures_lock = QMutex() + self._cache_lock = QReadWriteLock() + self._db_lock = QMutex() # Lock specifically for _db_handles access + self._db_handles = {} # Cache for LMDB database handles (dbi) + self._cancel_loading = False + self._cache_bytes_size = 0 + self._cache_writer = None + self._cache_loader = None + + self.lmdb_open() + + def lmdb_open(self): + # Initialize LMDB environment + cache_dir = Path(CONFIG_DIR) + cache_dir.mkdir(parents=True, exist_ok=True) + + try: + # map_size: 1024MB max database size + # max_dbs: 512 named databases (one per device + main DB) + self._lmdb_env = lmdb.open( + CACHE_PATH, + map_size=DISK_CACHE_MAX_BYTES, + max_dbs=512, + readonly=False, + create=True + ) + logger.info(f"LMDB cache opened: {CACHE_PATH}") + + # Start the async writer thread + self._cache_writer = CacheWriter(self) + self._cache_writer.start() + + # Start the async loader thread + self._cache_loader = CacheLoader(self) + self._cache_loader.start() + + except Exception as e: + logger.error(f"Failed to open LMDB cache: {e}") + self._lmdb_env = None + + def lmdb_close(self): + if hasattr(self, '_cache_writer') and self._cache_writer: + self._cache_writer.stop() + self._cache_writer = None + + if hasattr(self, '_cache_loader') and self._cache_loader: + self._cache_loader.stop() + self._cache_loader = None + self._loading_set.clear() + self._futures.clear() + + self._db_handles.clear() + if hasattr(self, '_lmdb_env') and self._lmdb_env: + self._lmdb_env.close() + self._lmdb_env = None + + def __del__(self): + self.lmdb_close() + + @staticmethod + def _get_inode_key(path): + """Get inode (8 bytes) for a file path.""" + try: + stat_info = os.stat(path) + # Pack inode as uint64 (8 bytes) + return struct.pack('Q', stat_info.st_ino) + except (OSError, AttributeError): + return None + + @staticmethod + def _get_device_id(path): + """Get device ID for a file path.""" + try: + stat_info = os.stat(path) + return stat_info.st_dev + except OSError: + return 0 + + def _get_device_db(self, device_id, size, write=False, txn=None): + """Get or create a named database for the given device.""" + env = self._lmdb_env + if not env: + return None + + db_name = f"dev_{device_id}_{size}".encode('utf-8') + + # Protect access to _db_handles which is not thread-safe by default + self._db_lock.lock() + try: + # Return cached handle if available + if db_name in self._db_handles: + return self._db_handles[db_name] + + # Not in cache, try to open/create + db = env.open_db(db_name, create=write, integerkey=False, txn=txn) + self._db_handles[db_name] = db + return db + + except lmdb.NotFoundError: + # Expected when reading from non-existent DB (cache miss) + return None + except Exception as e: + logger.error(f"Error opening device DB for dev {device_id} " + f"size {size}: {e}") + return None + finally: + self._db_lock.unlock() + + @contextmanager + def _write_lock(self): + """Context manager for write-safe access to cache.""" + self._cache_lock.lockForWrite() + try: + yield + finally: + self._cache_lock.unlock() + + @contextmanager + def _read_lock(self): + """Context manager for read-safe access to cache.""" + self._cache_lock.lockForRead() + try: + yield + finally: + self._cache_lock.unlock() + + def _ensure_cache_limit(self): + """Enforces cache size limit by evicting oldest entries. + Must be called with a write lock held.""" + # Safety limit: 512MB for thumbnails in RAM to prevent system freeze + MAX_RAM_BYTES = 512 * 1024 * 1024 + + while len(self._thumbnail_cache) > 0 and ( + len(self._thumbnail_cache) >= CACHE_MAX_SIZE or + self._cache_bytes_size > MAX_RAM_BYTES): + oldest_path = next(iter(self._thumbnail_cache)) + cached_sizes = self._thumbnail_cache.pop(oldest_path) + for img, _ in cached_sizes.values(): + if img: + self._cache_bytes_size -= img.sizeInBytes() + self._path_to_inode.pop(oldest_path, None) + + def _get_tier_for_size(self, requested_size): + """Determines the ideal thumbnail tier based on the requested size.""" + if requested_size < 192: + return 128 + if requested_size < 320: + return 256 + return 512 + + def _resolve_file_identity(self, path, curr_mtime, inode, device_id): + """Helper to resolve file mtime, device, and inode.""" + mtime = curr_mtime + dev_id = device_id if device_id is not None else 0 + inode_key = struct.pack('Q', inode) if inode is not None else None + + if mtime is None or dev_id == 0 or not inode_key: + try: + stat_res = os.stat(path) + mtime = stat_res.st_mtime + dev_id = stat_res.st_dev + inode_key = struct.pack('Q', stat_res.st_ino) + except OSError: + return None, 0, None + + return mtime, dev_id, inode_key + + def _queue_async_load(self, path, size, mtime, dev_id, inode_key): + """Helper to queue async load.""" + with self._write_lock(): + if (path, size) in self._loading_set: + # If already queued, promote to process sooner (LIFO) + if self._cache_loader: + self._cache_loader.promote(path, size) + return + + if not self._cache_loader: + return + + inode_int = struct.unpack('Q', inode_key)[0] if inode_key else 0 + + # Optimistically add to set + self._loading_set.add((path, size)) + success, dropped = self._cache_loader.enqueue( + (path, size, mtime, inode_int, dev_id)) + + if dropped: + self._loading_set.discard(dropped) + elif not success: + self._loading_set.discard((path, size)) + + def _check_disk_cache(self, path, search_order, mtime, dev_id, inode_key): + """Helper to check LMDB synchronously.""" + if not self._lmdb_env or not inode_key or dev_id == 0: + return None, 0 + + txn = None + try: + txn = self._lmdb_env.begin(write=False) + for size in search_order: + db = self._get_device_db(dev_id, size, write=False, txn=txn) + if not db: + continue + + value_bytes = txn.get(inode_key, db=db) + if value_bytes and len(value_bytes) > 8: + stored_mtime = struct.unpack('d', value_bytes[:8])[0] + if stored_mtime != mtime: + continue + + payload = value_bytes[8:] + if len(payload) > 4 and payload.startswith(b'PTH\0'): + path_len = struct.unpack('I', payload[4:8])[0] + img_data = payload[8 + path_len:] + else: + img_data = payload + + img = QImage() + if img.loadFromData(img_data, "PNG"): + with self._write_lock(): + if path not in self._thumbnail_cache: + self._ensure_cache_limit() + self._thumbnail_cache[path] = {} + self._thumbnail_cache[path][size] = (img, mtime) + self._cache_bytes_size += img.sizeInBytes() + self._path_to_inode[path] = (dev_id, inode_key) + return img, mtime + except Exception as e: + logger.debug(f"Cache lookup error for {path}: {e}") + finally: + if txn: + txn.abort() + + return None, 0 + + def get_thumbnail(self, path, requested_size, curr_mtime=None, + inode=None, device_id=None, async_load=False): + """ + Safely retrieve a thumbnail from cache, finding the best available size. + Returns: tuple (QImage or None, mtime) or (None, 0) if not found. + """ + # 1. Determine the ideal tier and create a prioritized search order. + target_tier = self._get_tier_for_size(requested_size) + search_order = [target_tier] + \ + sorted([s for s in THUMBNAIL_SIZES if s > target_tier]) + \ + sorted([s for s in THUMBNAIL_SIZES if s < target_tier], reverse=True) + + # 2. Resolve file identity (mtime, dev, inode) + mtime, dev_id, inode_key = self._resolve_file_identity( + path, curr_mtime, inode, device_id) + + if mtime is None: + return None, 0 + + # 3. Check memory cache (fastest) + with self._read_lock(): + cached_sizes = self._thumbnail_cache.get(path) + if cached_sizes: + for size in search_order: + if size in cached_sizes: + img, cached_mtime = cached_sizes[size] + if img and not img.isNull() and cached_mtime == mtime: + return img, mtime + + # 4. Handle Async Request + if async_load: + self._queue_async_load(path, target_tier, mtime, dev_id, inode_key) + return None, 0 + + # 5. Check Disk Cache (Sync fallback) + return self._check_disk_cache(path, search_order, mtime, dev_id, inode_key) + + def _mark_load_complete(self, path, size): + """Remove item from pending loading set.""" + with self._write_lock(): + # Uncomment to trace individual completions: + # logger.debug(f"Load complete: {path}") + self._loading_set.discard((path, size)) + + def set_thumbnail(self, path, img, mtime, size, inode=None, device_id=None, + block=False): + """Safely store a thumbnail of a specific size in cache.""" + if not img or img.isNull() or size not in THUMBNAIL_SIZES: + return False + + img_size = img.sizeInBytes() + + if inode is not None and device_id is not None: + dev_id = device_id + inode_key = struct.pack('Q', inode) + else: + dev_id = self._get_device_id(path) + inode_key = self._get_inode_key(path) + + if not inode_key or dev_id == 0: + return False + + with self._write_lock(): + if path not in self._thumbnail_cache: + self._ensure_cache_limit() + self._thumbnail_cache[path] = {} + else: + # Move to end to mark as recently used (LRU behavior) + # We pop and re-insert the existing entry + entry = self._thumbnail_cache.pop(path) + self._thumbnail_cache[path] = entry + + # If replacing, subtract old size + if size in self._thumbnail_cache.get(path, {}): + old_img, _ = self._thumbnail_cache[path][size] + if old_img: + self._cache_bytes_size -= old_img.sizeInBytes() + + self._thumbnail_cache[path][size] = (img, mtime) + self._path_to_inode[path] = (dev_id, inode_key) + self._cache_bytes_size += img_size + + # Enqueue asynchronous write to LMDB + if self._cache_writer: + self._cache_writer.enqueue( + (dev_id, inode_key, img, mtime, size, path), block=block) + return True + + def invalidate_path(self, path): + """Removes all cached data for a specific path from memory and disk.""" + inode_info = None + with self._write_lock(): + if path in self._thumbnail_cache: + cached_sizes = self._thumbnail_cache.pop(path) + for img, _ in cached_sizes.values(): + if img: + self._cache_bytes_size -= img.sizeInBytes() + inode_info = self._path_to_inode.pop(path, None) + + device_id, inode_key = inode_info if inode_info else (None, None) + self._delete_from_lmdb(path, device_id=device_id, inode_key=inode_key) + + def remove_if_missing(self, paths_to_check): + """Remove cache entries for files that no longer exist on disk.""" + to_remove = [] + + with self._read_lock(): + cached_paths = list(self._thumbnail_cache.keys()) + + for path in cached_paths: + if not os.path.exists(path): + to_remove.append(path) + + if to_remove: + entries_to_delete = [] + with self._write_lock(): + for path in to_remove: + if path in self._thumbnail_cache: + cached_sizes = self._thumbnail_cache.pop(path) + for img, _ in cached_sizes.values(): + if img: + self._cache_bytes_size -= img.sizeInBytes() + inode_info = self._path_to_inode.pop(path, None) + entries_to_delete.append((path, inode_info)) + # Delete from LMDB outside the lock + for path, inode_info in entries_to_delete: + device_id, inode_key = inode_info if inode_info else (None, None) + self._delete_from_lmdb(path, device_id=device_id, inode_key=inode_key) + logger.info(f"Removed {len(to_remove)} missing entries from cache") + + return len(to_remove) + + def clean_orphans(self, stop_check=None): + """ + Scans the LMDB database for entries where the original file no longer exists. + This is a heavy operation and should be run in a background thread. + """ + if not self._lmdb_env: + return 0 + + orphans_removed = 0 + + # 1. Get all named databases (one per device/size) + db_names = [] + try: + with self._lmdb_env.begin(write=False) as txn: + cursor = txn.cursor() + for key, _ in cursor: + if key.startswith(b'dev_'): + db_names.append(key) + except Exception as e: + logger.error(f"Error listing DBs: {e}") + return 0 + + # 2. Iterate each DB and check paths + for db_name in db_names: + if stop_check and stop_check(): + return orphans_removed + + try: + # Parse device ID from name "dev_{id}_{size}" + parts = db_name.decode('utf-8').split('_') + if len(parts) >= 3: + db_dev = int(parts[1]) + else: + db_dev = 0 + + # Parse device ID from name "dev_{id}_{size}" + # We open the DB to scan its keys + with self._lmdb_env.begin(write=True) as txn: + db = self._lmdb_env.open_db(db_name, txn=txn, create=False) + cursor = txn.cursor(db=db) + for key, value in cursor: + if stop_check and stop_check(): + return orphans_removed + + if len(value) > 12 and value[8:12] == b'PTH\0': + path_len = struct.unpack('I', value[12:16])[0] + path_bytes = value[16:16+path_len] + path = path_bytes.decode('utf-8', errors='ignore') + + should_delete = False + try: + st = os.stat(path) + key_inode = struct.unpack('Q', key)[0] + if st.st_dev != db_dev or st.st_ino != key_inode: + should_delete = True + except OSError: + should_delete = True + + if should_delete: + cursor.delete() + orphans_removed += 1 + except Exception as e: + logger.error(f"Error cleaning DB {db_name}: {e}") + + return orphans_removed + + def clear_cache(self): + """Clear both in-memory and LMDB cache.""" + with self._write_lock(): + self._thumbnail_cache.clear() + self._path_to_inode.clear() + self._cache_bytes_size = 0 + + self.lmdb_close() + try: + if os.path.exists(CACHE_PATH): + shutil.rmtree(CACHE_PATH) + self.lmdb_open() + logger.info("LMDB cache cleared by removing directory.") + except Exception as e: + logger.error(f"Error clearing LMDB by removing directory: {e}") + + def _batch_write_to_lmdb(self, batch): + """Write a batch of thumbnails to LMDB in a single transaction.""" + env = self._lmdb_env + if not env or not batch: + return + + data_to_write = [] + + # 1. Prepare data (image encoding) outside the transaction lock + # This is CPU intensive, better done without holding the DB lock if possible, + # though LMDB write lock mostly blocks other writers. + for item in batch: + # Small sleep to yield GIL/CPU to UI thread during heavy batch encoding + QThread.msleep(1) + + if len(item) == 6: + device_id, inode_key, img, mtime, size, path = item + else: + device_id, inode_key, img, mtime, size = item + path = None + + if not img or img.isNull(): + continue + + try: + img_data = self._image_to_bytes(img) + if not img_data: + continue + + # Pack mtime as a double (8 bytes) and prepend to image data + mtime_bytes = struct.pack('d', mtime) + + # New format: mtime(8) + 'PTH\0'(4) + len(4) + path + img + if path: + path_encoded = path.encode('utf-8') + header = mtime_bytes + b'PTH\0' + \ + struct.pack('I', len(path_encoded)) + path_encoded + value_bytes = header + img_data + else: + value_bytes = mtime_bytes + img_data + + data_to_write.append((device_id, size, inode_key, value_bytes)) + except Exception as e: + logger.error(f"Error converting image for LMDB: {e}") + + if not data_to_write: + return + + # 2. Commit to DB in one transaction + try: + with env.begin(write=True) as txn: + for device_id, size, inode_key, value_bytes in data_to_write: + # Ensure DB exists (creates if needed) using the current transaction + db = self._get_device_db(device_id, size, write=True, txn=txn) + if db: + try: + txn.put(inode_key, value_bytes, db=db) + except lmdb.Error as e: + # Handle potential stale DB handles (EINVAL) + # This happens if a previous transaction created the handle + # but aborted. + if "Invalid argument" in str(e): + db_name = f"dev_{device_id}_{size}".encode('utf-8') + self._db_lock.lock() + if db_name in self._db_handles: + del self._db_handles[db_name] + self._db_lock.unlock() + # Retry open and put with fresh handle + db = self._get_device_db( + device_id, size, write=True, txn=txn) + if db: + txn.put(inode_key, value_bytes, db=db) + else: + raise + except Exception as e: + logger.error(f"Error committing batch to LMDB: {e}") + # If transaction failed, handles created within it are now invalid. + # Clear cache to be safe. + self._db_lock.lock() + self._db_handles.clear() + self._db_lock.unlock() + + def _delete_from_lmdb(self, path, device_id=None, inode_key=None): + """Delete all thumbnail sizes for a path from LMDB.""" + env = self._lmdb_env + if not env: + return + + if device_id is None or inode_key is None: + device_id = self._get_device_id(path) + inode_key = self._get_inode_key(path) + + if not inode_key or device_id == 0: + return + + for size in THUMBNAIL_SIZES: + try: + db = self._get_device_db(device_id, size, write=True) + if not db: + continue + + with env.begin(write=True) as txn: + txn.delete(inode_key, db=db) + except Exception as e: + logger.error(f"Error deleting from LMDB for size {size}: {e}") + + @staticmethod + def _image_to_bytes(img): + """Convert QImage to PNG bytes.""" + buf = None + try: + ba = QByteArray() + buf = QBuffer(ba) + if not buf.open(QIODevice.WriteOnly): + logger.error("Failed to open buffer for image conversion") + return None + + if not img.save(buf, "PNG"): + logger.error("Failed to save image to buffer") + return None + return ba.data() + except Exception as e: + logger.error(f"Error converting image to bytes: {e}") + return None + finally: + if buf: + buf.close() + + def get_cache_stats(self): + """Get current cache statistics.""" + with self._read_lock(): + # Count all thumbnails, including different sizes of the same image. + count = sum(len(sizes) for sizes in self._thumbnail_cache.values()) + size = self._cache_bytes_size + return count, size + + def rename_entry(self, old_path, new_path): + """Move a cache entry from one path to another.""" + # Get new identity to check for cross-filesystem moves + try: + stat_info = os.stat(new_path) + new_dev = stat_info.st_dev + new_inode_key = struct.pack('Q', stat_info.st_ino) + except OSError: + return False + + entries_to_rewrite = [] + old_inode_info = None + + with self._write_lock(): + if old_path not in self._thumbnail_cache: + return False + + self._thumbnail_cache[new_path] = self._thumbnail_cache.pop(old_path) + old_inode_info = self._path_to_inode.pop(old_path, None) + + if old_inode_info: + self._path_to_inode[new_path] = (new_dev, new_inode_key) + # Always prepare data to persist to update the embedded path in LMDB + for size, (img, mtime) in self._thumbnail_cache[new_path].items(): + entries_to_rewrite.append((size, img, mtime)) + + # Perform LMDB operations outside the lock + if not old_inode_info: + return False + + old_dev, old_inode = old_inode_info + + # Only delete old key if physical identity changed (cross-filesystem moves) + if old_inode_info != (new_dev, new_inode_key): + self._delete_from_lmdb(old_path, device_id=old_dev, inode_key=old_inode) + + if self._cache_writer: + for size, img, mtime in entries_to_rewrite: + self._cache_writer.enqueue( + (new_dev, new_inode_key, img, mtime, size, new_path), + block=False) + + return True + + @contextmanager + def generation_lock(self, path, size, curr_mtime=None, inode=None, + device_id=None): + """ + Context manager to coordinate thumbnail generation between threads. + Prevents double work: if one thread is generating, others wait. + + Yields: + bool: True if the caller should generate the thumbnail. + False if the caller waited and should check cache again. + """ + # Resolve identity for locking key + mtime, dev_id, inode_key = self._resolve_file_identity( + path, curr_mtime, inode, device_id) + + if not inode_key or dev_id == 0: + # Cannot lock reliably without stable ID, allow generation + yield True + return + + key = (dev_id, inode_key, size) + future = None + owner = False + + self._futures_lock.lock() + if key in self._futures: + future = self._futures[key] + else: + future = GenerationFuture() + self._futures[key] = future + owner = True + self._futures_lock.unlock() + + if owner: + try: + yield True + finally: + future.complete() + self._futures_lock.lock() + if key in self._futures and self._futures[key] is future: + del self._futures[key] + self._futures_lock.unlock() + else: + # Another thread is generating, wait for it + future.wait() + yield False + + +class CacheCleaner(QThread): + """Background thread to remove cache entries for deleted files.""" + finished_clean = Signal(int) + + def __init__(self, cache): + super().__init__() + self.cache = cache + self._is_running = True + + def stop(self): + """Signals the thread to stop.""" + self._is_running = False + + def run(self): + self.setPriority(QThread.IdlePriority) + if not self._is_running: + return + # Perform deep cleaning of LMDB + removed_count = self.cache.remove_if_missing([]) + if self._is_running: + removed_count += self.cache.clean_orphans( + stop_check=lambda: not self._is_running) + self.finished_clean.emit(removed_count) + + +class ThumbnailRunnable(QRunnable): + """Runnable task to generate a single thumbnail.""" + def __init__(self, cache, path, size, signal_emitter): + super().__init__() + self.cache = cache + self.path = path + self.size = size + self.emitter = signal_emitter + + def run(self): + try: + # Optimization: Single stat call per file + stat_res = os.stat(self.path) + curr_mtime = stat_res.st_mtime + inode = stat_res.st_ino + dev = stat_res.st_dev + + # Check cache first to avoid expensive generation + thumb, mtime = self.cache.get_thumbnail( + self.path, self.size, curr_mtime=curr_mtime, + inode=inode, device_id=dev, async_load=False) + + if not thumb or mtime != curr_mtime: + # Use the generation lock to coordinate + with self.cache.generation_lock( + self.path, self.size, curr_mtime, inode, dev) as should_gen: + if should_gen: + # I am the owner, I generate the thumbnail + new_thumb = generate_thumbnail(self.path, self.size) + if new_thumb and not new_thumb.isNull(): + self.cache.set_thumbnail( + self.path, new_thumb, curr_mtime, self.size, + inode=inode, device_id=dev, block=True) + except Exception as e: + logger.error(f"Error generating thumbnail for {self.path}: {e}") + finally: + self.emitter.emit_progress() + + +class ThumbnailGenerator(QThread): + """ + Background thread to generate thumbnails for a specific size for a list of + already discovered files. + """ + generation_complete = Signal() + progress = Signal(int, int) # processed, total + + class SignalEmitter(QObject): + """Helper to emit signals from runnables to the main thread.""" + progress_tick = Signal() + + def emit_progress(self): + self.progress_tick.emit() + + def __init__(self, cache, paths, size): + super().__init__() + self.cache = cache + self.paths = paths + self.size = size + self._abort = False + + def stop(self): + """Stops the worker thread gracefully.""" + self._abort = True + self.wait() + + def run(self): + """ + Main execution loop. Uses a thread pool to process paths in parallel. + """ + pool = QThreadPool() + max_threads = APP_CONFIG.get( + "generation_threads", + SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4)) + pool.setMaxThreadCount(max_threads) + + emitter = self.SignalEmitter() + processed_count = 0 + total = len(self.paths) + + def on_tick(): + nonlocal processed_count + processed_count += 1 + if processed_count % 5 == 0 or processed_count == total: + self.progress.emit(processed_count, total) + + # Use a direct connection or queued connection depending on context, + # but since we are in QThread.run, we can connect local slot. + # However, runnables run in pool threads. We need thread-safe update. + # The signal/slot mechanism handles thread safety automatically. + emitter.progress_tick.connect(on_tick, Qt.QueuedConnection) + + for path in self.paths: + if self._abort: + break + runnable = ThumbnailRunnable(self.cache, path, self.size, emitter) + pool.start(runnable) + + pool.waitForDone() + self.generation_complete.emit() + + +class ImageScanner(QThread): + """ + Background thread for scanning directories and loading images. + """ + # List of tuples (path, QImage_thumb, mtime, tags, rating) + # Updated tuple: (path, QImage_thumb, mtime, tags, rating, inode, device_id) + images_found = Signal(list) + progress_msg = Signal(str) + progress_percent = Signal(int) + finished_scan = Signal(int) # Total images found + more_files_available = Signal(int, int) # Last loaded index, remainder + + def __init__(self, cache, paths, is_file_list=False, viewers=None): + # is_file_list is not used + if not paths or not isinstance(paths, (list, tuple)): + logger.warning("ImageScanner initialized with empty or invalid paths") + paths = [] + super().__init__() + self.cache = cache + self.all_files = [] + self._viewers = viewers + self._seen_files = set() + self._is_file_list = is_file_list + if self._is_file_list: + self.paths = [] + for p in paths: + if os.path.isfile(p): + p_str = str(p) + if p_str not in self._seen_files: + self.all_files.append(p_str) + self._seen_files.add(p_str) + path = os.path.dirname(p) + if path not in self.paths: + self.paths.append(path) + else: + self.paths.append(p) + else: + self.paths = paths + self._is_running = True + self._auto_load_enabled = False + self.count = 0 + self.index = 0 + self._paused = False + self.mutex = QMutex() + self.condition = QWaitCondition() + self.pending_tasks = [] + self._priority_queue = collections.deque() + self._processed_paths = set() + + # Initial load + self.pending_tasks.append((0, APP_CONFIG.get( + "scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"]))) + self._last_update_time = 0 + + logger.info(f"ImageScanner initialized with {len(paths)} paths") + + def set_auto_load(self, enabled): + """Enable or disable automatic loading of all subsequent images.""" + self._auto_load_enabled = enabled + + def set_paused(self, paused): + """Pauses or resumes the scanning process.""" + self._paused = paused + + def prioritize(self, paths): + """Adds paths to the priority queue for immediate processing.""" + self.mutex.lock() + for p in paths: + self._priority_queue.append(p) + self.mutex.unlock() + + def run(self): + # LowPriority ensures UI thread gets CPU time to process events (mouse, draw) + self.setPriority(QThread.IdlePriority) + self.progress_msg.emit(UITexts.SCANNING_DIRS) + self.scan_files() + + while self._is_running: + self.mutex.lock() + while not self.pending_tasks and self._is_running: + self.condition.wait(self.mutex) + + if not self._is_running: + self.mutex.unlock() + break + + i, to_load = self.pending_tasks.pop(0) + self.mutex.unlock() + + self._process_images(i, to_load) + + def _update_viewers(self, force=False): + if not self._viewers: + return + + current_time = time.time() + # Throttle updates to avoid saturating the event loop (max 5 times/sec) + if not force and (current_time - self._last_update_time < 0.2): + return + self._last_update_time = current_time + + # Image viewers standalone unique sort, this must be improved. + all_files_sorted_by_name_ascending = sorted(self.all_files) + # Iterate over a copy to avoid runtime errors if list changes in main thread + for w in list(self._viewers): + try: + if isinstance(w, ImageViewer): + QTimer.singleShot( + 0, w, lambda viewer=w, files=all_files_sorted_by_name_ascending: + viewer.update_image_list(files)) + except (RuntimeError, Exception): + # Handle cases where viewer might be closing/deleted (RuntimeError) or + # other issues + pass + + def scan_files(self): + for path in self.paths: + if not self._is_running: + return + try: + if path.startswith("file://"): + path = path[7:] + elif path.startswith("file:/"): + path = path[6:] + + # Check if path exists before resolving absolute path to avoid + # creating fake paths for search queries. + expanded_path = os.path.expanduser(path) + if os.path.exists(expanded_path): + p = Path(os.path.abspath(expanded_path)) + if p.is_file(): + p_str = str(p) + if p_str not in self._seen_files: + self.all_files.append(p_str) + self._seen_files.add(p_str) + elif p.is_dir(): + self._scan_directory(p, 0) + self._update_viewers() + else: + self._search(path) + self._update_viewers() + # logger.warning(f"Path not found: {p}") + except Exception as e: + logger.error(f"Error scanning path {path}: {e}") + + # Ensure final update reaches viewers + self._update_viewers(force=True) + + def _scan_directory(self, dir_path, current_depth): + if not self._is_running or current_depth > APP_CONFIG.get( + "scan_max_level", SCANNER_SETTINGS_DEFAULTS["scan_max_level"]): + return + try: + for item in dir_path.iterdir(): + if not self._is_running: + return + if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS: + p = os.path.abspath(str(item)) + if p not in self._seen_files: + self.all_files.append(p) + self._seen_files.add(p) + self._update_viewers() + elif item.is_dir(): + self._scan_directory(item, current_depth + 1) + except (PermissionError, OSError): + pass + + def _parse_query(self, query): + parser = argparse.ArgumentParser(prog="bagheera-search", add_help=False) + + # Main arguments + parser.add_argument("query", nargs="?", default="") + parser.add_argument("-d", "--directory") + parser.add_argument("-e", "--exclude", nargs="?", const="", + default=None) + parser.add_argument("-l", "--limit", type=int) + parser.add_argument("-o", "--offset", type=int) + parser.add_argument("-r", "--recursive", nargs="?", const="", default=None) + parser.add_argument("-x", "--recursive-exclude", nargs="?", const="", + default=None) + parser.add_argument("-s", "--sort") + parser.add_argument("-t", "--type") + + # Date filters + parser.add_argument("--day", type=int) + parser.add_argument("--month", type=int) + parser.add_argument("--year", type=int) + + try: + args_list = shlex.split(str(query)) + args, unknown_args = parser.parse_known_args(args_list) + + if args.day is not None and args.month is None: + raise ValueError("Missing --month (required when --day is used)") + + if args.month is not None and args.year is None: + raise ValueError("Missing --year (required when --month is used)") + + query_parts = [args.query] if args.query else [] + if unknown_args: + query_parts.extend(unknown_args) + + query_text = " ".join(query_parts) + + # Build options dictionary + main_options = {} + if args.recursive is not None: + main_options["type"] = "folder" + else: + if args.limit is not None: + main_options["limit"] = args.limit + if args.offset is not None: + main_options["offset"] = args.offset + if args.type: + main_options["type"] = args.type + + if args.directory: + main_options["directory"] = args.directory + if args.year is not None: + main_options["year"] = args.year + if args.month is not None: + main_options["month"] = args.month + if args.day is not None: + main_options["day"] = args.day + if args.sort: + main_options["sort"] = args.sort + if args.exclude and args.exclude == '': + args.recursive_exclude = None + if args.recursive_exclude and args.recursive_exclude == '': + args.recursive_exclude = None + other_options = { + "exclude": args.exclude, + "id": False, + "konsole": False, + "limit": args.limit if args.limit and args.recursive is not None + else 99999999999, + "offset": args.offset if args.offset and args.recursive is not None + else 0, + "recursive": args.recursive, + "recursive_indent": "", + "recursive_exclude": args.recursive_exclude, + "sort": args.sort, + "type": args.type if args.recursive is not None else None, + "verbose": False, + } + + return query_text, main_options, other_options + + except ValueError as e: + print(f"Arguments error: {e}") + return None, [] + except Exception as e: + print(f"Unexpected error parsing query: {e}") + return None, [] + + def _search(self, query): + engine = APP_CONFIG.get("search_engine", "Native") + if HAVE_BAGHEERASEARCH_LIB and (engine == "Native" or not SEARCH_CMD): + query_text, main_options, other_options = self._parse_query(query) + try: + searcher = BagheeraSearcher() + for item in searcher.search(query_text, main_options, other_options): + if not self._is_running: + break + p = item["path"].strip() + if p and os.path.exists(os.path.expanduser(p)): + if p not in self._seen_files: + self.all_files.append(p) + self._seen_files.add(p) + self._update_viewers() + except Exception as e: + print(f"Error during bagheerasearch library call: {e}") + + elif SEARCH_CMD: + try: + cmd = SEARCH_CMD + shlex.split(str(query)) + out = subprocess.check_output(cmd, text=True).splitlines() + for p in out: + if not self._is_running: + break + p = p.strip() + if p.startswith("file://"): + p = p[7:] + if p and os.path.exists(os.path.expanduser(p)): + if p not in self._seen_files: + self.all_files.append(p) + self._seen_files.add(p) + self._update_viewers() + except Exception as e: + print(f"Error during {SEARCH_CMD} subprocess call: {e}") + + def load_images(self, i, to_load): + if i < 0: + i = 0 + if i >= len(self.all_files): + return + self.mutex.lock() + self.pending_tasks.append((i, to_load)) + self.condition.wakeAll() + self.mutex.unlock() + + def _process_images(self, i, to_load): + + if i >= len(self.all_files): + self.finished_scan.emit(self.count) + return + + images_loaded = 0 + batch = [] + while i < len(self.all_files): + + if not self._is_running: + return + self.msleep(1) # Force yield to UI thread per item + + while self._paused and self._is_running: + self.msleep(100) + + # 1. Check priority queue first + priority_path = None + self.mutex.lock() + while self._priority_queue: + p = self._priority_queue.popleft() + if p not in self._processed_paths and p in self._seen_files: + priority_path = p + break + self.mutex.unlock() + + # 2. Determine file to process + if priority_path: + f_path = priority_path + # Don't increment 'i' yet, we are processing out of order + else: + f_path = self.all_files[i] + i += 1 # Only advance sequential index if processing sequentially + + if f_path not in self._processed_paths \ + and Path(f_path).suffix.lower() in IMAGE_EXTENSIONS: + # Pass the batch list to store result instead of emitting immediately + was_loaded = self._process_single_image(f_path, batch) + + # Emit batch if size is enough (responsiveness optimization) + # Dynamic batching: Start small for instant feedback. + # Keep batches small enough to prevent UI starvation during rapid cache + # reads. + if self.count <= 100: + target_batch_size = 20 + else: + target_batch_size = 200 + + if len(batch) >= target_batch_size: + + self.images_found.emit(batch) + batch = [] + # Yield briefly to let the main thread process the emitted batch + # (update UI), preventing UI freeze during fast cache reading. + self.msleep(10) + + if was_loaded: + self._processed_paths.add(f_path) + images_loaded += 1 + if images_loaded >= to_load and to_load > 0: + if batch: # Emit remaining items + self.images_found.emit(batch) + + next_index = i + 1 + total_files = len(self.all_files) + self.index = next_index + self.progress_msg.emit(UITexts.LOADED_PARTIAL.format( + self.count, total_files - next_index)) + + if total_files > 0: + percent = int((self.count / total_files) * 100) + self.progress_percent.emit(percent) + + self.more_files_available.emit(next_index, total_files) + # This loads all images continuously without pausing only if + # explicitly requested + if self._auto_load_enabled: + self.load_images( + next_index, + APP_CONFIG.get("scan_batch_size", + SCANNER_SETTINGS_DEFAULTS[ + "scan_batch_size"])) + return + + if self.count % 10 == 0: # Update progress less frequently + self.progress_msg.emit( + UITexts.LOADING_SCAN.format(self.count, len(self.all_files))) + if len(self.all_files) > 0: + percent = int((self.count / len(self.all_files)) * 100) + self.progress_percent.emit(percent) + + self.index = len(self.all_files) + if batch: + self.images_found.emit(batch) + self.progress_percent.emit(100) + self.finished_scan.emit(self.count) + + def _load_metadata(self, path_or_fd): + """Loads tag and rating data for a path or file descriptor.""" + tags = [] + + raw_tags = XattrManager.get_attribute(path_or_fd, XATTR_NAME) + if raw_tags: + tags = sorted(list(set(t.strip() + for t in raw_tags.split(',') if t.strip()))) + + raw_rating = XattrManager.get_attribute(path_or_fd, RATING_XATTR_NAME, "0") + try: + rating = int(raw_rating) + except ValueError: + rating = 0 + return tags, rating + + def _process_single_image(self, f_path, batch_list): + from constants import SCANNER_GENERATE_SIZES + + fd = None + try: + # Optimize: Open file once to reuse FD for stat and xattrs + fd = os.open(f_path, os.O_RDONLY) + stat_res = os.fstat(fd) + curr_mtime = stat_res.st_mtime + curr_inode = stat_res.st_ino + curr_dev = stat_res.st_dev + + smallest_thumb_for_signal = None + + # Ensure required thumbnails exist + for size in SCANNER_GENERATE_SIZES: + # Check if a valid thumbnail for this size exists + thumb, mtime = self.cache.get_thumbnail(f_path, size, + curr_mtime=curr_mtime, + inode=curr_inode, + device_id=curr_dev) + if not thumb or mtime != curr_mtime: + # Use generation lock to prevent multiple threads generating the + # same thumb + with self.cache.generation_lock( + f_path, size, curr_mtime, + curr_inode, curr_dev) as should_gen: + if should_gen: + # I am the owner, I generate the thumbnail + new_thumb = generate_thumbnail(f_path, size) + if new_thumb and not new_thumb.isNull(): + self.cache.set_thumbnail( + f_path, new_thumb, curr_mtime, size, + inode=curr_inode, device_id=curr_dev, block=True) + if size == min(SCANNER_GENERATE_SIZES): + smallest_thumb_for_signal = new_thumb + else: + # Another thread generated it, re-fetch to use it for the + # signal + if size == min(SCANNER_GENERATE_SIZES): + re_thumb, _ = self.cache.get_thumbnail( + f_path, size, curr_mtime=curr_mtime, + inode=curr_inode, device_id=curr_dev, + async_load=False) + smallest_thumb_for_signal = re_thumb + elif size == min(SCANNER_GENERATE_SIZES): + # valid thumb exists, use it for signal + smallest_thumb_for_signal = thumb + + tags, rating = self._load_metadata(fd) + batch_list.append((f_path, smallest_thumb_for_signal, + curr_mtime, tags, rating, curr_inode, curr_dev)) + self.count += 1 + return True + except Exception as e: + logger.error(f"Error processing image {f_path}: {e}") + return False + finally: + if fd is not None: + try: + os.close(fd) + except OSError: + pass + + def stop(self): + self._is_running = False + self.mutex.lock() + self.condition.wakeAll() + self.mutex.unlock() + self.wait() diff --git a/imageviewer.py b/imageviewer.py new file mode 100644 index 0000000..3b2a1ff --- /dev/null +++ b/imageviewer.py @@ -0,0 +1,2703 @@ + +"""Image Viewer Module for Bagheera. + +This module implements the main image viewing window (ImageViewer) and its +associated widgets, such as the filmstrip thumbnail browser (FilmStripWidget). +It provides functionalities for navigating, zooming, rotating, and presenting +images in a slideshow mode. + +Classes: + ImageViewer: A standalone window for viewing and manipulating an image. +""" +import os +import subprocess +import json + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QScrollArea, QLabel, QHBoxLayout, QListWidget, + QListWidgetItem, QAbstractItemView, QMenu, QInputDialog, QDialog, QDialogButtonBox, + QApplication, QMessageBox, QLineEdit, QFileDialog +) +from PySide6.QtGui import ( + QPixmap, QIcon, QTransform, QDrag, QPainter, QPen, QColor, QAction, QCursor, + QImageReader, QMovie, QKeySequence, QPainterPath, QImage +) +from PySide6.QtCore import ( + Signal, QPoint, QSize, Qt, QMimeData, QUrl, QTimer, QEvent, QRect, Slot, QRectF, + QThread +) + +from constants import ( + APP_CONFIG, DEFAULT_FACE_BOX_COLOR, DEFAULT_PET_BOX_COLOR, DEFAULT_VIEWER_SHORTCUTS, + DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_BOX_COLOR, FORCE_X11, ICON_THEME_VIEWER, + ICON_THEME_VIEWER_FALLBACK, KSCREEN_DOCTOR_MARGIN, KWINOUTPUTCONFIG_PATH, + VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_FORM_MARGIN, VIEWER_LABEL, + VIEWER_WHEEL_SPEED_DEFAULT, XATTR_NAME, ZOOM_DESKTOP_RATIO, UITexts, +) +from imagecontroller import ImageController +from widgets import FaceNameInputWidget +from propertiesdialog import PropertiesDialog + + +class FaceNameDialog(QDialog): + """A dialog to get a face name using the FaceNameInputWidget.""" + def __init__(self, parent=None, history=None, current_name="", main_win=None, + region_type="Face"): + super().__init__(parent) + if region_type == "Pet": + self.setWindowTitle(UITexts.ADD_PET_TITLE) + layout_label = UITexts.ADD_PET_LABEL + elif region_type == "Object": + self.setWindowTitle(UITexts.ADD_OBJECT_TITLE) + layout_label = UITexts.ADD_OBJECT_LABEL + elif region_type == "Landmark": + self.setWindowTitle(UITexts.ADD_LANDMARK_TITLE) + layout_label = UITexts.ADD_LANDMARK_LABEL + else: + self.setWindowTitle(UITexts.ADD_FACE_TITLE) + layout_label = UITexts.ADD_FACE_LABEL + self.setMinimumWidth(350) + self.main_win = main_win + + layout = QVBoxLayout(self) + layout.addWidget(QLabel(layout_label)) + + # Our custom widget. + self.name_input = FaceNameInputWidget(self.main_win, self, + region_type=region_type) + self.name_input.load_data(history or []) + if current_name: + self.name_input.name_combo.setEditText(current_name.split('/')[-1]) + + self.name_input.name_combo.lineEdit().selectAll() + + layout.addWidget(self.name_input) + + # OK / Cancel buttons. + self.button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + layout.addWidget(self.button_box) + + # Connections. + self.button_box.accepted.connect(self.name_input._on_accept) + self.button_box.rejected.connect(self.reject) + self.name_input.name_accepted.connect(self._on_tag_selected) + + self.final_tag = "" + self.final_history = history or [] + + def _on_tag_selected(self, full_tag): + """Handles the signal from the input widget and closes the dialog.""" + self.final_tag = full_tag + self.final_history = self.name_input.get_history() + super().accept() + + @staticmethod + def get_name(parent=None, history=None, current_name="", main_win=None, + region_type="Face", title=None): + """Static method to show the dialog and get the result.""" + dialog = FaceNameDialog(parent, history, current_name, main_win, region_type) + if title: + dialog.setWindowTitle(title) + result = dialog.exec() + if result == QDialog.Accepted: + return dialog.final_tag, dialog.final_history, True + # Return original history if cancelled + return current_name, history, False + + +class FilmstripLoader(QThread): + """Thread to load filmstrip thumbnails asynchronously.""" + thumbnail_loaded = Signal(int, QImage) + + def __init__(self, cache, items_to_load, icon_size): + super().__init__() + self.cache = cache + # items_to_load is a list of (index, path) tuples + self.items = items_to_load + self.icon_size = icon_size + self.target_index = 0 + self._abort = False + self._sort_needed = True + + def set_target_index(self, index): + if self.target_index != index: + self.target_index = index + self._sort_needed = True + + def run(self): + self.setPriority(QThread.IdlePriority) + while self.items: + if self._abort: + return + + if self._sort_needed: + # Pick the item closest to the target index (visible area) + # Sorting descending allows O(1) pop from end + self.items.sort( + key=lambda x: abs(x[0] - self.target_index), reverse=True) + self._sort_needed = False + + index, path = self.items.pop() + + # Small sleep to prevent UI freezing during heavy IO bursts + self.msleep(1) + try: + img, _ = self.cache.get_thumbnail(path, self.icon_size) + if img and not img.isNull(): + self.thumbnail_loaded.emit(index, img) + except Exception: + pass + + def stop(self): + self._abort = True + self.wait() + + +class FastTagManager: + """Manages the creation and interaction of the fast tag menu.""" + def __init__(self, viewer): + self.viewer = viewer + self.main_win = viewer.main_win + self.controller = viewer.controller + + def show_menu(self): + """Builds and shows a context menu for quickly adding/removing tags.""" + if not self.main_win or not self.controller.get_current_path(): + return + + current_path = self.controller.get_current_path() + try: + raw_tags = os.getxattr(current_path, XATTR_NAME).decode('utf-8') + current_tags = {t.strip() for t in raw_tags.split(',') if t.strip()} + except (OSError, AttributeError): + current_tags = set() + + mru_tags = list(self.main_win.mru_tags) \ + if hasattr(self.main_win, 'mru_tags') else [] + + if not mru_tags and not current_tags: + return + + menu = FastTagMenu(self) + + mru_tags_to_show = [tag for tag in mru_tags if tag not in current_tags] + if mru_tags_to_show: + for tag in mru_tags_to_show: + action = menu.addAction(tag) + if '/' in tag: + action.setProperty("is_hierarchical", True) + menu.style().unpolish(menu) + menu.style().polish(menu) + action.setCheckable(True) + action.setChecked(False) + + if mru_tags_to_show and current_tags: + menu.addSeparator() + + if current_tags: + for tag in sorted(list(current_tags)): + action = menu.addAction(tag) + if '/' in tag: + action.setProperty("is_hierarchical", True) + menu.style().unpolish(menu) + menu.style().polish(menu) + action.setCheckable(True) + action.setChecked(True) + + menu.ensurePolished() + + actions = menu.actions() + if actions: + first_action = next((a for a in actions if not a.isSeparator()), None) + if first_action: + menu.setActiveAction(first_action) + menu.exec(QCursor.pos()) + + def on_tag_toggled(self, action): + """Handles the toggling of a tag from the fast tag menu.""" + if not isinstance(action, QAction): + return + tag_name = action.text() + is_checked = action.isChecked() + current_path = self.controller.get_current_path() + if not current_path: + return + self.controller.toggle_tag(tag_name, is_checked) + self.viewer.update_status_bar() + if self.main_win: + if is_checked: + self.main_win.add_to_mru_tags(tag_name) + self.main_win.update_metadata_for_path(current_path) + + +class FastTagMenu(QMenu): + """A QMenu that allows toggling actions with the mouse or spacebar without " + "closing.""" + def __init__(self, manager): + super().__init__(manager.viewer) + self.manager = manager + self.setStyleSheet(""" + QMenu::item[is_hierarchical="true"] { + color: #a9d0f5; /* Light blue for hierarchical tags */ + padding-left: 20px; + } + """) + + def mouseReleaseEvent(self, event): + action = self.actionAt(event.pos()) + if action and action.isCheckable(): + action.setChecked(not action.isChecked()) + if self.manager: + self.manager.on_tag_toggled(action) + event.accept() + else: + super().mouseReleaseEvent(event) + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Space or event.key() == Qt.Key_Return: + action = self.activeAction() + if action and action.isCheckable(): + # Manually toggle and trigger to keep menu open + action.setChecked(not action.isChecked()) + if self.manager: + self.manager.on_tag_toggled(action) + event.accept() + if event.key() == Qt.Key_Space: + return + super().keyPressEvent(event) + + +class FilmStripWidget(QListWidget): + """ + A horizontal, scrollable list of thumbnails for image navigation. + + This widget displays thumbnails for all images in the current list, + allowing the user to quickly jump to an image by clicking its thumbnail. + It also supports dragging files out of the application. + """ + def __init__(self, controller, parent=None): + """ + Initializes the FilmStripWidget. + + Args: + controller (ImageController): The controller managing the image list. + parent (QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.controller = controller + self.setDragEnabled(True) + + def startDrag(self, supportedActions): + """ + Initiates a drag-and-drop operation for the selected image(s). + """ + items = self.selectedItems() + if not items: + return + + urls = [] + for item in items: + row = self.row(item) + if 0 <= row < len(self.controller.image_list): + path = self.controller.image_list[row] + urls.append(QUrl.fromLocalFile(path)) + + if not urls: + return + + drag = QDrag(self) + mime_data = QMimeData() + mime_data.setUrls(urls) + drag.setMimeData(mime_data) + + icon = items[0].icon() + if not icon.isNull(): + pixmap = icon.pixmap(64, 64) + drag.setPixmap(pixmap) + drag.setHotSpot(QPoint(pixmap.width() // 2, pixmap.height() // 2)) + + drag.exec(Qt.CopyAction) + + +class FaceCanvas(QLabel): + """ + A custom QLabel that draws face regions on top of the image. + Handles mouse interaction for creating and managing face regions. + """ + def __init__(self, viewer): + super().__init__() + self.viewer = viewer + self.controller = viewer.controller + self.setMouseTracking(True) + self.drawing = False + self.start_pos = QPoint() + self.current_rect = QRect() + self.dragging = False + self.drag_start_pos = QPoint() + self.drag_start_scroll_x = 0 + self.drag_start_scroll_y = 0 + self.editing = False + self.edit_index = -1 + self.edit_handle = None + self.edit_start_rect = QRect() + self.resize_margin = 8 + self.crop_rect = QRect() + self.crop_handle = None + self.crop_start_pos = QPoint() + self.crop_start_rect = QRect() + + def map_from_source(self, face_data): + """Maps original normalized face data to current canvas QRect.""" + nx = face_data.get('x', 0) + ny = face_data.get('y', 0) + nw = face_data.get('w', 0) + nh = face_data.get('h', 0) + + rot = self.controller.rotation % 360 + flip_h = self.controller.flip_h + flip_v = self.controller.flip_v + + cx, cy = nx, ny + cw, ch = nw, nh + + # 1. Rotation (applied to normalized center coordinates 0.5, 0.5) + if rot == 90: + cx, cy = 1.0 - cy, cx + cw, ch = nh, nw + elif rot == 180: + cx, cy = 1.0 - cx, 1.0 - cy + elif rot == 270: + cx, cy = cy, 1.0 - cx + cw, ch = nh, nw + + # 2. Flips + if flip_h: + cx = 1.0 - cx + if flip_v: + cy = 1.0 - cy + + w = self.width() + h = self.height() + + # Convert Center-Normalized to Top-Left-Pixel + rx = (cx - cw / 2) * w + ry = (cy - ch / 2) * h + rw = cw * w + rh = ch * h + + return QRect(int(rx), int(ry), int(rw), int(rh)) + + def map_to_source(self, rect): + """Maps a canvas QRect to original normalized coordinates (x, y, w, h).""" + w = self.width() + h = self.height() + if w == 0 or h == 0: + return 0.5, 0.5, 0.0, 0.0 + + # Pixel Rect to Normalized Center + cx = (rect.x() + rect.width() / 2.0) / w + cy = (rect.y() + rect.height() / 2.0) / h + cw = rect.width() / w + ch = rect.height() / h + + rot = self.controller.rotation % 360 + flip_h = self.controller.flip_h + flip_v = self.controller.flip_v + + # Inverse Flips + if flip_h: + cx = 1.0 - cx + if flip_v: + cy = 1.0 - cy + + # Inverse Rotation + nx, ny = cx, cy + nw, nh = cw, ch + + if rot == 90: + # Inverse of 90 is 270: (y, 1-x) + nx, ny = cy, 1.0 - cx + nw, nh = ch, cw + elif rot == 180: + nx, ny = 1.0 - cx, 1.0 - cy + elif rot == 270: + # Inverse of 270 is 90: (1-y, x) + nx, ny = 1.0 - cy, cx + nw, nh = ch, cw + + return nx, ny, nw, nh + + def paintEvent(self, event): + super().paintEvent(event) + if not self.controller.show_faces and not self.viewer.crop_mode: + return + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Draw existing faces + face_color_str = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR) + face_color = QColor(face_color_str) + pet_color_str = APP_CONFIG.get("pet_box_color", DEFAULT_PET_BOX_COLOR) + pet_color = QColor(pet_color_str) + object_color_str = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR) + object_color = QColor(object_color_str) + landmark_color_str = APP_CONFIG.get("landmark_box_color", + DEFAULT_LANDMARK_BOX_COLOR) + landmark_color = QColor(landmark_color_str) + + if self.controller.show_faces: + for face in self.controller.faces: + rect = self.map_from_source(face) + + is_pet = face.get('type') == 'Pet' + is_object = face.get('type') == 'Object' + is_landmark = face.get('type') == 'Landmark' + + if is_pet: + color = pet_color + elif is_object: + color = object_color + elif is_landmark: + color = landmark_color + else: + color = face_color + + painter.setPen(QPen(color, 2)) + painter.setBrush(Qt.NoBrush) + painter.drawRect(rect) + + name = face.get('name', '') + if name: + display_name = name.split('/')[-1] + fm = painter.fontMetrics() + tw = fm.horizontalAdvance(display_name) + th = fm.height() + + bg_height = th + 4 + bg_width = tw + 8 + # Default position is top-left, outside the box + bg_y = rect.top() - bg_height + + # If there is no space at the top, move it to the bottom + if bg_y < 0: + bg_y = rect.bottom() + + bg_rect = QRect(rect.left(), bg_y, bg_width, bg_height) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(100, 100, 100)) + painter.drawRect(bg_rect) + painter.setPen(QPen(color, 1)) + painter.drawText(bg_rect, Qt.AlignCenter, display_name) + + # Draw rubber band for new face + if self.drawing and not self.viewer.crop_mode: + painter.setPen(QPen(QColor(0, 120, 255), 2, Qt.DashLine)) + painter.setBrush(Qt.NoBrush) + painter.drawRect(self.current_rect) + + # Draw crop rectangle if in crop mode + if self.viewer.crop_mode and not self.crop_rect.isNull(): + # Draw dimmed overlay outside crop rect + painter.setBrush(QColor(0, 0, 0, 160)) + painter.setPen(Qt.NoPen) + path = QPainterPath() + path.addRect(QRectF(self.rect())) + path.addRect(QRectF(self.crop_rect)) + path.setFillRule(Qt.OddEvenFill) + painter.drawPath(path) + + painter.setPen(QPen(QColor(255, 255, 0), 2, Qt.DashLine)) + painter.setBrush(Qt.NoBrush) + painter.drawRect(self.crop_rect) + + # Draw rule of thirds grid + if self.crop_rect.width() > 60 and self.crop_rect.height() > 60: + grid_pen = QPen(QColor(255, 255, 255, 100), 1, Qt.SolidLine) + painter.setPen(grid_pen) + + x, y = self.crop_rect.x(), self.crop_rect.y() + w, h = self.crop_rect.width(), self.crop_rect.height() + + x1, x2 = int(x + w / 3), int(x + 2 * w / 3) + y1, y2 = int(y + h / 3), int(y + 2 * h / 3) + + painter.drawLine(x1, y, x1, y + h) + painter.drawLine(x2, y, x2, y + h) + painter.drawLine(x, y1, x + w, y1) + painter.drawLine(x, y2, x + w, y2) + + # Draw crop handles + painter.setPen(QPen(QColor(0, 0, 0), 1)) + painter.setBrush(QColor(255, 255, 255)) + handle_size = 8 + offset = handle_size // 2 + handles = [ + self.crop_rect.topLeft(), self.crop_rect.topRight(), + self.crop_rect.bottomLeft(), self.crop_rect.bottomRight() + ] + for pt in handles: + painter.drawRect(pt.x() - offset, pt.y() - offset, + handle_size, handle_size) + + def _hit_test(self, pos): + """Determines if the mouse is over a name, handle, or body.""" + if not self.controller.show_faces: + return -1, None + + margin = self.resize_margin + fm = self.fontMetrics() + + # Iterate in reverse to pick top-most face if overlapping + for i in range(len(self.controller.faces) - 1, -1, -1): + face = self.controller.faces[i] + rect = self.map_from_source(face) + + # Check if click is on the name label first + name = face.get('name', '') + if name: + display_name = name.split('/')[-1] + tw = fm.horizontalAdvance(display_name) + th = fm.height() + bg_height = th + 4 + bg_width = tw + 8 + bg_y = rect.top() - bg_height + if bg_y < 0: + bg_y = rect.bottom() + + bg_rect = QRect(rect.left(), bg_y, bg_width, bg_height) + if bg_rect.contains(pos): + return i, 'NAME' + + # Check outer boundary with margin + outer = rect.adjusted(-margin, -margin, margin, margin) + if not outer.contains(pos): + continue + + x, y = pos.x(), pos.y() + l, t, r, b = rect.left(), rect.top(), rect.right(), rect.bottom() + + # Determine proximity to edges + on_left = abs(x - l) <= margin + on_right = abs(x - r) <= margin + on_top = abs(y - t) <= margin + on_bottom = abs(y - b) <= margin + + # Check Corners + if on_left and on_top: + return i, 'TL' + if on_right and on_top: + return i, 'TR' + if on_left and on_bottom: + return i, 'BL' + if on_right and on_bottom: + return i, 'BR' + + # Check Edges + if on_left: + return i, 'L' + if on_right: + return i, 'R' + if on_top: + return i, 'T' + if on_bottom: + return i, 'B' + + if rect.contains(pos): + return i, 'BODY' + return -1, None + + def _hit_test_crop(self, pos): + """Determines if mouse is over a crop handle or body.""" + if self.crop_rect.isNull(): + return None + + handle_size = 12 # Hit area slightly larger than drawn handle + # margin = handle_size // 2 + + rects = { + 'TL': QRect(0, 0, handle_size, handle_size), + 'TR': QRect(0, 0, handle_size, handle_size), + 'BL': QRect(0, 0, handle_size, handle_size), + 'BR': QRect(0, 0, handle_size, handle_size) + } + rects['TL'].moveCenter(self.crop_rect.topLeft()) + rects['TR'].moveCenter(self.crop_rect.topRight()) + rects['BL'].moveCenter(self.crop_rect.bottomLeft()) + rects['BR'].moveCenter(self.crop_rect.bottomRight()) + + for key, r in rects.items(): + if r.contains(pos): + return key + + if self.crop_rect.contains(pos): + return 'BODY' + return None + + def mousePressEvent(self, event): + """Handles mouse press for drawing new faces or panning.""" + self.viewer.reset_inactivity_timer() + if self.viewer.crop_mode and event.button() == Qt.LeftButton: + handle = self._hit_test_crop(event.position().toPoint()) + if handle: + self.crop_handle = handle + self.crop_start_pos = event.position().toPoint() + self.crop_start_rect = self.crop_rect + event.accept() + return + + self.drawing = True + self.start_pos = event.position().toPoint() + self.crop_rect = QRect() + self.update() + event.accept() + return + + if self.controller.show_faces and event.button() == Qt.LeftButton: + self.start_pos = event.position().toPoint() + + # Check if we clicked on an existing face to edit + idx, handle = self._hit_test(self.start_pos) + if idx != -1: + if handle == 'NAME': + self.viewer.rename_face(self.controller.faces[idx]) + event.accept() + return + + self.editing = True + self.edit_index = idx + self.edit_handle = handle + self.edit_start_rect = self.map_from_source(self.controller.faces[idx]) + event.accept() + else: + self.drawing = True + self.current_rect = QRect(self.start_pos, self.start_pos) + event.accept() + elif event.button() == Qt.LeftButton: + self.dragging = True + self.drag_start_pos = event.globalPosition().toPoint() + self.drag_start_scroll_x = self.viewer.scroll_area.horizontalScrollBar().value() + self.drag_start_scroll_y = self.viewer.scroll_area.verticalScrollBar().value() + self.setCursor(Qt.ClosedHandCursor) + event.accept() + else: + event.ignore() + + def mouseMoveEvent(self, event): + """Handles mouse move for drawing new faces or panning.""" + self.viewer.reset_inactivity_timer() + if self.viewer.crop_mode: + curr_pos = event.position().toPoint() + + if self.crop_handle: + dx = curr_pos.x() - self.crop_start_pos.x() + dy = curr_pos.y() - self.crop_start_pos.y() + rect = QRect(self.crop_start_rect) + + if self.crop_handle == 'BODY': + rect.translate(dx, dy) + # Bounds check + rect.moveLeft(max(0, rect.left())) + rect.moveTop(max(0, rect.top())) + if rect.right() > self.width(): + rect.moveRight(self.width()) + if rect.bottom() > self.height(): + rect.moveBottom(self.height()) + else: + # Determine fixed anchor point based on handle + if self.crop_handle == 'TL': + fixed = self.crop_start_rect.bottomRight() + moving = self.crop_start_rect.topLeft() + elif self.crop_handle == 'TR': + fixed = self.crop_start_rect.bottomLeft() + moving = self.crop_start_rect.topRight() + elif self.crop_handle == 'BL': + fixed = self.crop_start_rect.topRight() + moving = self.crop_start_rect.bottomLeft() + elif self.crop_handle == 'BR': + fixed = self.crop_start_rect.topLeft() + moving = self.crop_start_rect.bottomRight() + + # Calculate new moving point candidate + current_moving = moving + QPoint(dx, dy) + + # Vector from fixed to moving + w = current_moving.x() - fixed.x() + h = current_moving.y() - fixed.y() + + # Aspect ratio constraint with Shift + if event.modifiers() & Qt.ShiftModifier \ + and self.crop_start_rect.height() != 0: + ratio = self.crop_start_rect.width() / \ + self.crop_start_rect.height() + if abs(w) / ratio > abs(h): + h = w / ratio + else: + w = h * ratio + + rect = QRect(fixed, QPoint(int(fixed.x() + w), int(fixed.y() + h))) + + self.crop_rect = rect.normalized() + self.update() + event.accept() + return + elif self.drawing: + if event.modifiers() & Qt.ShiftModifier: + dx = curr_pos.x() - self.start_pos.x() + dy = curr_pos.y() - self.start_pos.y() + side = max(abs(dx), abs(dy)) + curr_pos = QPoint( + self.start_pos.x() + (side if dx >= 0 else -side), + self.start_pos.y() + (side if dy >= 0 else -side)) + self.crop_rect = QRect(self.start_pos, + curr_pos).normalized() + self.update() + event.accept() + return + else: + # Cursor update + handle = self._hit_test_crop(curr_pos) + if handle in ['TL', 'BR']: + self.setCursor(Qt.SizeFDiagCursor) + elif handle in ['TR', 'BL']: + self.setCursor(Qt.SizeBDiagCursor) + elif handle == 'BODY': + self.setCursor(Qt.SizeAllCursor) + else: + self.setCursor(Qt.CrossCursor) + event.accept() + return + + if self.drawing: + self.current_rect = QRect(self.start_pos, + event.position().toPoint()).normalized() + self.update() + event.accept() + elif self.editing: + curr_pos = event.position().toPoint() + dx = curr_pos.x() - self.start_pos.x() + dy = curr_pos.y() - self.start_pos.y() + + # Calculate new rect based on handle + new_rect = QRect(self.edit_start_rect) + min_size = 5 + + if self.edit_handle == 'BODY': + new_rect.translate(dx, dy) + else: + if 'L' in self.edit_handle: + new_rect.setLeft( + min(new_rect.right() - min_size, + self.edit_start_rect.left() + dx)) + if 'R' in self.edit_handle: + new_rect.setRight( + max(new_rect.left() + min_size, + self.edit_start_rect.right() + dx)) + if 'T' in self.edit_handle: + new_rect.setTop( + min(new_rect.bottom() - min_size, + self.edit_start_rect.top() + dy)) + if 'B' in self.edit_handle: + new_rect.setBottom( + max(new_rect.top() + min_size, + self.edit_start_rect.bottom() + dy)) + + # Normalize and update face data + # Convert screen rect back to normalized source coordinates + nx, ny, nw, nh = self.map_to_source(new_rect) + + # Update the face in the controller in real-time + face = self.controller.faces[self.edit_index] + face['x'], face['y'], face['w'], face['h'] = nx, ny, nw, nh + + self.update() + event.accept() + elif not self.drawing and not self.dragging and self.controller.show_faces: + # Update cursor based on hover + _, handle = self._hit_test(event.position().toPoint()) + if handle in ['TL', 'BR']: + self.setCursor(Qt.SizeFDiagCursor) + elif handle in ['TR', 'BL']: + self.setCursor(Qt.SizeBDiagCursor) + elif handle in ['T', 'B']: + self.setCursor(Qt.SizeVerCursor) + elif handle in ['L', 'R']: + self.setCursor(Qt.SizeHorCursor) + elif handle == 'BODY': + self.setCursor(Qt.SizeAllCursor) + elif handle == 'NAME': + self.setCursor(Qt.PointingHandCursor) + else: + self.setCursor(Qt.CrossCursor) + event.accept() + elif self.dragging: + delta = event.globalPosition().toPoint() - self.drag_start_pos + h_bar = self.viewer.scroll_area.horizontalScrollBar() + v_bar = self.viewer.scroll_area.verticalScrollBar() + h_bar.setValue(self.drag_start_scroll_x - delta.x()) + v_bar.setValue(self.drag_start_scroll_y - delta.y()) + event.accept() + else: + event.ignore() + + def mouseReleaseEvent(self, event): + """Handles mouse release for drawing new faces or panning.""" + if self.viewer.crop_mode: + if self.crop_handle: + self.crop_handle = None + self.update() + elif self.drawing: + self.drawing = False + self.update() + + event.accept() + return + + if self.drawing: + self.drawing = False + if self.current_rect.width() > 10 and self.current_rect.height() > 10: + region_type = "Face" + # Check if Control key was held down to allow selecting type + if event.modifiers() & Qt.ControlModifier: + menu = QMenu(self) + action_face = menu.addAction(UITexts.TYPE_FACE) + action_pet = menu.addAction(UITexts.TYPE_PET) + action_object = menu.addAction(UITexts.TYPE_OBJECT) + action_landmark = menu.addAction(UITexts.TYPE_LANDMARK) + # Show menu at mouse release position + res = menu.exec(event.globalPosition().toPoint()) + if res == action_pet: + region_type = "Pet" + elif res == action_object: + region_type = "Object" + elif res == action_landmark: + region_type = "Landmark" + elif res == action_face: + region_type = "Face" + else: + # Cancelled + self.current_rect = QRect() + self.update() + return + + history_list = [] + if self.viewer.main_win: + if region_type == "Pet": + history_list = self.viewer.main_win.pet_names_history + elif region_type == "Object": + history_list = self.viewer.main_win.object_names_history + elif region_type == "Landmark": + history_list = self.viewer.main_win.landmark_names_history + else: + history_list = self.viewer.main_win.face_names_history + + history = history_list \ + if self.viewer.main_win else [] + + full_tag, updated_history, ok = FaceNameDialog.get_name( + self.viewer, history, + main_win=self.viewer.main_win, region_type=region_type) + + if ok and full_tag: + if self.viewer.main_win: + if region_type == "Pet": + self.viewer.main_win.pet_names_history = updated_history + elif region_type == "Object": + self.viewer.main_win.object_names_history = updated_history + elif region_type == "Landmark": + self.viewer.main_win.landmark_names_history = \ + updated_history + else: + self.viewer.main_win.face_names_history = updated_history + + center_x, center_y, norm_w, norm_h = self.map_to_source( + self.current_rect) + + self.controller.add_face( + full_tag, center_x, center_y, norm_w, norm_h, + region_type=region_type) + self.controller.toggle_tag(full_tag, True) + self.update() # Repaint to show the new face with its name + self.current_rect = QRect() + self.update() + event.accept() + elif self.editing: + # Finish editing + self.editing = False + self.edit_index = -1 + self.edit_handle = None + self.controller.save_faces() + event.accept() + elif self.dragging: + self.dragging = False + self.setCursor(Qt.ArrowCursor) + event.accept() + else: + event.ignore() + + def mouseDoubleClickEvent(self, event): + """Zooms to a face on double-click.""" + if self.controller.show_faces and event.button() == Qt.LeftButton: + # The event position is already local to the canvas + clicked_face = self.viewer._get_clicked_face(event.position().toPoint()) + if clicked_face: + self.viewer.zoom_to_rect(clicked_face) + event.accept() + return + # If no face was double-clicked, pass the event on + super().mouseDoubleClickEvent(event) + + +class ImageViewer(QWidget): + """ + A standalone window for viewing and manipulating a single image. + + This viewer supports navigation (next/previous) through a list of images, + zooming, panning, rotation, mirroring, and a slideshow mode. It also + integrates a filmstrip for quick navigation and a status bar for showing + image information. + + Signals: + index_changed(int): Emitted when the current image index changes. + """ + index_changed = Signal(int) + activated = Signal() + + def __init__(self, cache, image_list, current_index, initial_tags=None, + initial_rating=0, parent=None, + restore_config=None, persistent=False, first_load=True): + """ + Initializes the ImageViewer window. + + Args: + cache (ThumbnailCache): The thumbnail cache instance. + image_list (list): The list of image paths to display. + current_index (int): The starting index in the image_list. + parent (QWidget, optional): The parent widget (MainWindow). Defaults to + None. + restore_config (dict, optional): A state dictionary to restore a previous + session. Defaults to None. + persistent (bool, optional): If True, the viewer is part of a saved layout. + Defaults to False. + """ + super().__init__() + self.main_win = parent + self.cache = cache + self.set_window_icon() + self.setAttribute(Qt.WA_DeleteOnClose) + # Standard window buttons + self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint | + Qt.WindowMinimizeButtonHint) + + self._first_load = first_load + self._is_persistent = persistent + self.crop_mode = False + self._wheel_scroll_accumulator = 0 + self.filmstrip_loader = None + + self.movie = None + self.controller = ImageController(image_list, current_index, + initial_tags, initial_rating) + if self.main_win: + self.controller.show_faces = self.main_win.show_faces + self.controller.metadata_changed.connect(self.on_metadata_changed) + self.controller.list_updated.connect(self.on_controller_list_updated) + self.fast_tag_manager = FastTagManager(self) + self._setup_shortcuts() + self._setup_actions() + + self.inhibit_cookie = None + + filmstrip_position = 'bottom' # Default + if self.main_win and hasattr(self.main_win, 'filmstrip_position'): + filmstrip_position = self.main_win.filmstrip_position + + # UI Layout + is_vertical_filmstrip = filmstrip_position in ('left', 'right') + if is_vertical_filmstrip: + self.layout = QHBoxLayout(self) + else: + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(0) + + self.scroll_area = QScrollArea() + self.scroll_area.setAlignment(Qt.AlignCenter) + self.scroll_area.setStyleSheet("background-color: #000; border: none;") + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self.canvas = FaceCanvas(self) + self.scroll_area.setWidget(self.canvas) + + self.filmstrip = FilmStripWidget(self.controller) + self.filmstrip.setSpacing(2) + self.filmstrip.itemClicked.connect(self.on_filmstrip_clicked) + + self.status_bar_container = QWidget() + self.status_bar_container.setStyleSheet("background-color: #222; color: #aaa; " + "font-size: 11px;") + sb_layout = QHBoxLayout(self.status_bar_container) + sb_layout.setContentsMargins(5, 2, 5, 2) + self.sb_index_label = QLabel() + self.sb_tags_label = QLabel() + self.sb_info_label = QLabel() + self.sb_info_label.setAlignment(Qt.AlignRight) + sb_layout.addWidget(self.sb_index_label) + sb_layout.addWidget(self.sb_tags_label) + sb_layout.addStretch() + sb_layout.addWidget(self.sb_info_label) + + if is_vertical_filmstrip: + center_pane = QWidget() + center_layout = QVBoxLayout(center_pane) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + center_layout.addWidget(self.scroll_area) + center_layout.addWidget(self.status_bar_container) + + self.filmstrip.setFixedWidth(120) + self.filmstrip.setViewMode(QListWidget.IconMode) + self.filmstrip.setFlow(QListWidget.TopToBottom) + self.filmstrip.setWrapping(False) + self.filmstrip.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.filmstrip.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.filmstrip.setIconSize(QSize(100, 100)) + + border_side = "border-right" if filmstrip_position == 'left' \ + else "border-left" + self.filmstrip.setStyleSheet(f"QListWidget {{ background-color: #222; " + f"{border_side}: 1px solid #444; }} " + "QListWidget::item:selected " + "{ background-color: #3498db; }") + + if filmstrip_position == 'left': + self.layout.addWidget(self.filmstrip) + self.layout.addWidget(center_pane) + else: + self.layout.addWidget(center_pane) + self.layout.addWidget(self.filmstrip) + else: + self.filmstrip.setFixedHeight(100) + self.filmstrip.setViewMode(QListWidget.IconMode) + self.filmstrip.setFlow(QListWidget.LeftToRight) + self.filmstrip.setWrapping(False) + self.filmstrip.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.filmstrip.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.filmstrip.setIconSize(QSize(80, 80)) + + border_side = "border-top" if filmstrip_position == 'bottom' \ + else "border-bottom" + self.filmstrip.setStyleSheet(f"QListWidget {{ background-color: #222; " + f"{border_side}: 1px solid #444; }} " + "QListWidget::item:selected " + "{ background-color: #3498db; }") + + if filmstrip_position == 'top': + self.layout.addWidget(self.filmstrip) + self.layout.addWidget(self.scroll_area) + self.layout.addWidget(self.status_bar_container) + else: # bottom + self.layout.addWidget(self.scroll_area) + self.layout.addWidget(self.filmstrip) + self.layout.addWidget(self.status_bar_container) + + if self.main_win: + self.filmstrip.setVisible(self.main_win.show_filmstrip) + else: + self.filmstrip.setVisible(False) + + if self.main_win: + self.status_bar_container.setVisible(self.main_win.show_viewer_status_bar) + + self.inhibit_screensaver() + + # Inactivity timer for fullscreen + self.hide_controls_timer = QTimer(self) + self.hide_controls_timer.setInterval(3000) + self.hide_controls_timer.timeout.connect(self.hide_controls) + + # Slideshow + self.slideshow_timer = QTimer(self) + self.slideshow_timer.setInterval(3000) + self.slideshow_timer.timeout.connect(self.next_image) + + # Slideshow + self.slideshow_reverse_timer = QTimer(self) + self.slideshow_reverse_timer.setInterval(3000) + self.slideshow_reverse_timer.timeout.connect(self.prev_image) + + # Load image + if restore_config: + # If restoring a layout, don't auto-fit to screen. Instead, use + # the saved geometry and state. + QTimer.singleShot(1000, self.restore_image_list) + self.populate_filmstrip() + self.load_and_fit_image(restore_config) + else: + self.populate_filmstrip() + self.load_and_fit_image() + + def reset_inactivity_timer(self): + """Resets the inactivity timer and restores controls visibility.""" + if self.isFullScreen(): + self.unsetCursor() + if self.main_win and self.main_win.show_viewer_status_bar: + self.status_bar_container.show() + if not self.hide_controls_timer.isActive(): + self.hide_controls_timer.start() + else: + self.hide_controls_timer.start() # Restart triggers full interval + + def hide_controls(self): + """Hides cursor and status bar in fullscreen mode.""" + if self.isFullScreen(): + self.setCursor(Qt.BlankCursor) + self.status_bar_container.hide() + + def _setup_shortcuts(self): + """Initializes the shortcut mapping from the main window or defaults.""" + self.action_to_shortcut = {} + if self.main_win and self.main_win.viewer_shortcuts: + # The dictionary from main_win is already prepared with + # (int, Qt.KeyboardModifiers) keys. The value is (action, desc). + # We just need the action. + self.shortcuts = {key: val[0] + for key, val in self.main_win.viewer_shortcuts.items()} + for key, action in self.shortcuts.items(): + self.action_to_shortcut[action] = key + else: + # Use defaults from the new constant structure + self.shortcuts = {} + for action, (key, mods) in DEFAULT_VIEWER_SHORTCUTS.items(): + key_combo = (int(key), Qt.KeyboardModifiers(mods)) + self.shortcuts[key_combo] = action + self.action_to_shortcut[action] = key_combo + + def _setup_actions(self): + """Initializes the action map for executing shortcuts.""" + self._actions = { + "close": self.close_or_exit_fullscreen, + "next": self.next_image, + "prev": self.prev_image, + "slideshow": self.toggle_slideshow, + "slideshow_reverse": self.toggle_slideshow_reverse, + "fullscreen": self.toggle_fullscreen, + "rename": self.rename_current_image, + "toggle_faces": self.toggle_faces, + "toggle_statusbar": self.toggle_status_bar, + "toggle_filmstrip": self.toggle_filmstrip, + "flip_horizontal": self.toggle_flip_horizontal, + "flip_vertical": self.toggle_flip_vertical, + "detect_faces": self.run_face_detection, + "detect_pets": self.run_pet_detection, + "fast_tag": self.show_fast_tag_menu, + "rotate_right": lambda: self.apply_rotation(90, True), + "rotate_left": lambda: self.apply_rotation(-90, True), + "zoom_in": lambda: self.zoom(1.1), + "zoom_out": lambda: self.zoom(0.9), + "reset_zoom": lambda: self.zoom(1.0, reset=True), + "toggle_animation": self.toggle_animation_pause, + "properties": self.show_properties, + "toggle_visibility": self.toggle_main_window_visibility, + "toggle_crop": self.toggle_crop_mode, + "save_crop": self.save_cropped_image, + } + + def _execute_action(self, action): + """Executes the method corresponding to the action name.""" + if action in self._actions: + self._actions[action]() + + def populate_filmstrip(self): + """ + Populates the filmstrip widget with thumbnails from the image list. + + Optimized to update the existing list if possible, rather than + rebuilding it entirely. + """ + if not self.filmstrip.isVisible(): + return + + # --- OPTIMIZATION --- + # Check if the filmstrip content is already in sync with the controller's list. + # If so, just update the selection and avoid a full rebuild. + new_list = self.controller.image_list + if self.filmstrip.count() == len(new_list): + is_synced = True + # This check is fast enough for typical filmstrip sizes. + for i in range(len(new_list)): + # Assuming UserRole stores the path + if self.filmstrip.item(i).data(Qt.UserRole) != new_list[i]: + is_synced = False + break + if is_synced: + self.sync_filmstrip_selection(self.controller.index) + return + # --- END OPTIMIZATION --- + + if self.filmstrip_loader and self.filmstrip_loader.isRunning(): + self.filmstrip_loader.stop() + + current_count = self.filmstrip.count() + fallback_icon = QIcon.fromTheme("image-x-generic") + + # Check if we can perform an incremental update (append) + can_append = True + if current_count > len(new_list): + can_append = False + else: + for i in range(current_count): + item = self.filmstrip.item(i) + if item.data(Qt.UserRole) != new_list[i]: + can_append = False + break + + if can_append: + # Append only new items + for i in range(current_count, len(new_list)): + path = new_list[i] + item = QListWidgetItem(fallback_icon, "") + item.setData(Qt.UserRole, path) + self.filmstrip.addItem(item) + else: + # Smart rebuild: reuse items to preserve icons/loaded state + existing_items = {} + # Remove from end to beginning to avoid index shifting overhead + for i in range(self.filmstrip.count() - 1, -1, -1): + item = self.filmstrip.takeItem(i) + existing_items[item.data(Qt.UserRole)] = item + + for path in new_list: + if path in existing_items: + item = existing_items.pop(path) + else: + item = QListWidgetItem(fallback_icon, "") + item.setData(Qt.UserRole, path) + self.filmstrip.addItem(item) + + # Determine which items need thumbnail loading + items_to_load = [] + LOADED_ROLE = Qt.UserRole + 1 + + for i in range(self.filmstrip.count()): + item = self.filmstrip.item(i) + if not item.data(LOADED_ROLE): + path = item.data(Qt.UserRole) + items_to_load.append((i, path)) + + if items_to_load: + self.filmstrip_loader = FilmstripLoader( + self.cache, items_to_load, self.filmstrip.iconSize().width()) + self.filmstrip_loader.set_target_index(self.controller.index) + self.filmstrip_loader.thumbnail_loaded.connect( + self._on_filmstrip_thumb_loaded) + self.filmstrip_loader.start() + + # Defer selection sync to ensure the list widget has updated its layout + # and bounds, fixing issues where the wrong item seems selected or scrolled to. + QTimer.singleShot( + 0, lambda: self.sync_filmstrip_selection(self.controller.index)) + + @Slot(int, QImage) + def _on_filmstrip_thumb_loaded(self, index, image): + """Updates the filmstrip item icon once the thumbnail is loaded.""" + if 0 <= index < self.filmstrip.count(): + item = self.filmstrip.item(index) + item.setIcon(QIcon(QPixmap.fromImage(image))) + # Mark as loaded to prevent reloading on subsequent updates + item.setData(Qt.UserRole + 1, True) + + def sync_filmstrip_selection(self, index): + """ + Highlights the thumbnail in the filmstrip corresponding to the given index. + Args: + index (int): The index of the image to select in the filmstrip. + """ + if self.filmstrip.count() == 0: + return + if 0 <= index < self.filmstrip.count(): + item = self.filmstrip.item(index) + self.filmstrip.setCurrentItem(item) + self.filmstrip.scrollToItem(item, QAbstractItemView.PositionAtCenter) + + # Update loader priority if running + if self.filmstrip_loader and self.filmstrip_loader.isRunning(): + self.filmstrip_loader.set_target_index(index) + + def on_filmstrip_clicked(self, item): + """ + Slot that handles clicks on a filmstrip item. + + Args: + item (QListWidgetItem): The clicked list widget item. + """ + idx = self.filmstrip.row(item) + if idx != self.controller.index: + self.controller.index = idx + self.index_changed.emit(self.controller.index) + self.load_and_fit_image() + + def restore_image_list(self): + """Restores the full image list from the main window. + + This is used when a viewer is restored from a layout, ensuring its + internal image list is synchronized with the main application's list. + """ + current_path = self.controller.get_current_path() + image_paths = self.main_win.get_all_image_paths() + if current_path and current_path in image_paths: + index = image_paths.index(current_path) + if index >= 0: + self.controller.update_list(image_paths, index) + + def get_desktop_resolution(self): + """ + Determines the resolution of the primary desktop. + """ + try: + """ + kwinoutputconfig.json + """ + # We run kscreen-doctor and look for the primary monitor line. + if FORCE_X11: + if os.path.exists(KWINOUTPUTCONFIG_PATH): + scale = 1 + primary_monitor = subprocess.check_output("xrandr | grep " + "' primary' | cut -d' '" + " -f1", + shell=True, + text=True).strip() + try: + with open(KWINOUTPUTCONFIG_PATH, 'r', encoding='utf-8') as f: + data_json = json.load(f) + + # Find the section where "name" is "outputs" + outputs_section = next((item for item in data_json if + item.get("name") == "outputs"), None) + + if outputs_section: + # Iterate over the "data" elements within that section + for device in outputs_section.get("data", []): + if device.get("connectorName") == primary_monitor: + scale = float(device.get("scale")) + mode = device.get("mode", {}) + output = f"{mode.get('width')}x{mode.get('height')}" + break + + except json.JSONDecodeError: + scale = 1 + output = subprocess.check_output("xrandr | grep ' primary' | " + "awk '{print $4}' | cut -d'+' " + "-f1", shell=True, text=True) + except Exception: + scale = 1 + output = subprocess.check_output("xrandr | grep ' primary' | " + "awk '{print $4}' | cut -d'+' " + "-f1", shell=True, text=True) + else: + scale = 1 + output = subprocess.check_output("xrandr | grep ' primary' | " + "awk '{print $4}' | cut -d'+' " + "-f1", shell=True, text=True) + + width, height = map(int, output.split('x')) + return width / scale - KSCREEN_DOCTOR_MARGIN, height / scale - \ + KSCREEN_DOCTOR_MARGIN + + else: + # This can hang on X11. + output = subprocess.check_output("kscreen-doctor -o | grep -A 10 " + "'priority 1' | grep 'Geometry' " + "| cut -d' ' -f3", shell=True, + text=True) + width, height = map(int, output.split('x')) + return width-KSCREEN_DOCTOR_MARGIN, height-KSCREEN_DOCTOR_MARGIN + except Exception: + screen_geo = self.screen().availableGeometry() + return screen_geo.width(), screen_geo.height() + + def load_and_fit_image(self, restore_config=None): + """ + Loads the current image and calculates an appropriate initial zoom level. + + If restoring from a config, it applies the saved zoom and scroll. + Otherwise, it fits the image to the screen, respecting a defined ratio. + + Args: + restore_config (dict, optional): State dictionary to restore from. + """ + if self.movie: + self.movie.stop() + self.movie = None + + if not self.controller.load_image(): + self.canvas.setPixmap(QPixmap()) + self.update_status_bar() + return + + path = self.controller.get_current_path() + self.canvas.crop_rect = QRect() # Clear crop rect on new image + + if path: + reader = QImageReader(path) + if reader.supportsAnimation() and reader.imageCount() > 1: + self.movie = QMovie(path) + self.movie.setCacheMode(QMovie.CacheAll) + self.movie.frameChanged.connect(self._on_movie_frame) + self.movie.start() + + self.reset_inactivity_timer() + if restore_config: + self.controller.zoom_factor = restore_config.get("zoom", 1.0) + self.controller.rotation = restore_config.get("rotation", 0) + self.controller.show_faces = restore_config.get( + "show_faces", self.controller.show_faces) + self.status_bar_container.setVisible( + restore_config.get("status_bar_visible", False)) + self.filmstrip.setVisible( + restore_config.get("filmstrip_visible", False)) + if self.filmstrip.isVisible(): + self.populate_filmstrip() + self.update_view(resize_win=False) + QTimer.singleShot(0, lambda: self.restore_scroll(restore_config)) + else: + # Calculate zoom to fit the image on the screen + if self.isFullScreen(): + viewport = self.scroll_area.viewport() + available_w = viewport.width() + available_h = viewport.height() + should_resize = False + else: + if self._first_load: + if self.main_win and self.main_win.isVisible(): + # Get resolution from main windows + screen_geo = self.main_win.screen().availableGeometry() + screen_width = screen_geo.width() + screen_height = screen_geo.height() + else: + # Tried to guess + screen_width, screen_height = self.get_desktop_resolution() + self._first_load = False + else: + screen_geo = self.screen().availableGeometry() + screen_width = screen_geo.width() + screen_height = screen_geo.height() + + # Calculate available screen space for the image itself + available_w = screen_width * ZOOM_DESKTOP_RATIO + available_h = screen_height * ZOOM_DESKTOP_RATIO + + filmstrip_position = self.main_win.filmstrip_position \ + if self.main_win else 'bottom' + + if self.filmstrip.isVisible(): + if filmstrip_position in ('left', 'right'): + available_w -= self.filmstrip.width() + else: # top, bottom + available_h -= self.filmstrip.height() + + if self.status_bar_container.isVisible(): + available_h -= self.status_bar_container.sizeHint().height() + should_resize = True + + orig_w = self.controller.pixmap_original.width() + orig_h = self.controller.pixmap_original.height() + + if orig_w > 0 and orig_h > 0: + factor = min(available_w / orig_w, available_h / orig_h) + if self.isFullScreen(): + self.controller.zoom_factor = factor + else: + self.controller.zoom_factor = min(1.0, factor) + else: + self.controller.zoom_factor = 1.0 + + self.update_view(resize_win=should_resize) + + # Defer sync to ensure layout and scroll area are ready, fixing navigation sync + QTimer.singleShot( + 0, lambda: self.sync_filmstrip_selection(self.controller.index)) + + @Slot(list) + def update_image_list(self, new_list): + """Updates the controller's image list ensuring the current image remains + selected.""" + current_path = self.controller.get_current_path() + + # If controller is empty but we have a new list, perform initial load logic + if not current_path and new_list: + self.controller.update_list(new_list, 0) + self.load_and_fit_image() + return + + final_list = list(new_list) + new_index = -1 + + if current_path: + # 1. Try exact match + try: + new_index = final_list.index(current_path) + except ValueError: + # 2. Try normpath match (fixes slashes/dots issues) + norm_current = os.path.normpath(current_path) + abs_current = os.path.abspath(current_path) + real_current = os.path.realpath(current_path) + for i, path in enumerate(final_list): + if os.path.normpath(path) == norm_current or \ + os.path.abspath(path) == abs_current or \ + os.path.realpath(path) == real_current: + new_index = i + break + + # 3. If still not found, add it to preserve context + if new_index == -1: + if current_path not in final_list: + final_list.append(current_path) + final_list.sort() + try: + new_index = final_list.index(current_path) + except ValueError: + new_index = 0 + + if new_index != -1: + self.controller.update_list(final_list, new_index) + else: + # If current path lost, just update list, index defaults/clamps + self.controller.update_list(final_list) + # Only reload if the path actually changed effectively + if self.controller.get_current_path() != current_path: + self.load_and_fit_image() + + @Slot(int) + def on_controller_list_updated(self, new_index): + """Handles the controller's list_updated signal to refresh the UI.""" + self.populate_filmstrip() + self.update_status_bar(index=new_index) + + def _on_movie_frame(self): + """Updates the view with the current frame from the movie.""" + if self.movie and self.movie.isValid(): + self.controller.pixmap_original = self.movie.currentPixmap() + self.update_view(resize_win=False) + + def toggle_animation_pause(self): + """Pauses or resumes the current animation.""" + if self.movie: + is_paused = self.movie.state() == QMovie.Paused + self.movie.setPaused(not is_paused) + self.update_title() + + def zoom(self, factor, reset=False): + """Applies zoom to the image.""" + if reset: + self.controller.zoom_factor = 1.0 + self.update_view(resize_win=True) + else: + self.controller.zoom_factor *= factor + self.update_view(resize_win=True) + + # Notify the main window that the image (and possibly index) has changed + # so it can update its selection. + self.index_changed.emit(self.controller.index) + + self.sync_filmstrip_selection(self.controller.index) + + def zoom_to_rect(self, face_rect): + """Zooms and pans the view to center on a given normalized rectangle.""" + if self.controller.pixmap_original.isNull(): + return + + viewport = self.scroll_area.viewport() + vp_w = viewport.width() + vp_h = viewport.height() + + # Use the original pixmap dimensions for zoom calculation + transform = QTransform().rotate(self.controller.rotation) + transformed_pixmap = self.controller.pixmap_original.transformed( + transform, Qt.SmoothTransformation) + img_w = transformed_pixmap.width() + img_h = transformed_pixmap.height() + + if img_w == 0 or img_h == 0: + return + + # Calculate the size of the face in original image pixels + face_pixel_w = face_rect['w'] * img_w + face_pixel_h = face_rect['h'] * img_h + + if face_pixel_w == 0 or face_pixel_h == 0: + return + + # Calculate zoom factor to make the face fill ~70% of the viewport + zoom_w = (vp_w * 0.7) / face_pixel_w + zoom_h = (vp_h * 0.7) / face_pixel_h + new_zoom = min(zoom_w, zoom_h) + + self.controller.zoom_factor = new_zoom + self.update_view(resize_win=False) + + # Defer centering until after the view has been updated + QTimer.singleShot(0, lambda: self._center_on_face(face_rect)) + + def _center_on_face(self, face_rect): + """Scrolls the viewport to center on the face.""" + canvas_w = self.canvas.width() + canvas_h = self.canvas.height() + + viewport = self.scroll_area.viewport() + vp_w = viewport.width() + vp_h = viewport.height() + + # Face center in the newly zoomed canvas coordinates + face_center_x_px = face_rect['x'] * canvas_w + face_center_y_px = face_rect['y'] * canvas_h + + # Calculate the target scrollbar value to center the point + scroll_x = face_center_x_px - (vp_w / 2) + scroll_y = face_center_y_px - (vp_h / 2) + + self.scroll_area.horizontalScrollBar().setValue(int(scroll_x)) + self.scroll_area.verticalScrollBar().setValue(int(scroll_y)) + + def apply_rotation(self, rotation, resize_win=False): + """ + Applies a rotation to the current image. + + Args: + rotation (int): The angle in degrees to rotate by. + resize_win (bool): If True, the window will be resized. Defaults to False. + """ + if self.controller.pixmap_original.isNull(): + return + self.controller.rotate(rotation) + self.update_view(resize_win) + + def update_view(self, resize_win=False): + """ + Updates the canvas with the current pixmap, applying zoom and rotation. + + This is the main rendering method. It gets the transformed pixmap from + the controller and displays it. + + Args: + resize_win (bool): If True, the window resizes to fit the image. + """ + pixmap = self.controller.get_display_pixmap() + if pixmap.isNull(): + return + + self.canvas.setPixmap(pixmap) + self.canvas.adjustSize() + + if resize_win and APP_CONFIG.get("viewer_auto_resize_window", + VIEWER_AUTO_RESIZE_WINDOW_DEFAULT): + # Adjust window size to content + content_w = self.canvas.width() + content_h = self.canvas.height() + + filmstrip_position = self.main_win.filmstrip_position \ + if self.main_win else 'bottom' + is_vertical_filmstrip = filmstrip_position in ('left', 'right') + + if self.status_bar_container.isVisible(): + content_h += self.status_bar_container.sizeHint().height() + + if self.filmstrip.isVisible(): + if is_vertical_filmstrip: + content_w += self.filmstrip.width() + else: # top, bottom + content_h += self.filmstrip.height() + + target_w = content_w + VIEWER_FORM_MARGIN + target_h = content_h + VIEWER_FORM_MARGIN + + # Use robust resolution detection for standalone mode to fix sizing issues + if not self.isVisible() and ( + not self.main_win or not self.main_win.isVisible()): + sw, sh = self.get_desktop_resolution() + target_w = min(target_w, sw) + target_h = min(target_h, sh) + else: + screen = self.screen() + if not self.isVisible() and self.main_win and self.main_win.isVisible(): + screen = self.main_win.screen() + avail_geo = screen.availableGeometry() + target_w = min(target_w, avail_geo.width()) + target_h = min(target_h, avail_geo.height()) + self.resize(target_w, target_h) + + self.update_title() + self.update_status_bar() + + def rename_current_image(self): + """ + Opens a dialog to rename the current image file. + + Handles the file system rename operation and updates the internal state. + """ + if not self.controller.image_list: + return + + old_path = self.controller.get_current_path() + if not old_path: + return + + old_dir = os.path.dirname(old_path) + old_filename = os.path.basename(old_path) + base_name, extension = os.path.splitext(old_filename) + + new_base, ok = QInputDialog.getText( + self, UITexts.RENAME_VIEWER_TITLE, + UITexts.RENAME_VIEWER_TEXT.format(old_filename), + QLineEdit.Normal, base_name + ) + + if ok and new_base and new_base != base_name: + new_base_name, new_extension = os.path.splitext(new_base) + if new_extension == extension: + new_filename = new_base + else: + new_filename = new_base_name + extension + + new_path = os.path.join(old_dir, new_filename) + + if self.movie: + self.movie.stop() + self.movie = None + + if os.path.exists(new_path): + QMessageBox.warning(self, UITexts.ERROR, + UITexts.RENAME_VIEWER_ERROR_EXISTS.format( + new_filename)) + return + + try: + os.rename(old_path, new_path) + self.controller.image_list[self.controller.index] = new_path + if self.main_win: + self.main_win.propagate_rename(old_path, new_path, self) + self.update_view(resize_win=False) + self.populate_filmstrip() + self.update_title() + except Exception as e: + QMessageBox.critical(self, + UITexts.RENAME_VIEWER_ERROR_SYSTEM, + UITexts.RENAME_VIEWER_ERROR_TEXT.format(str(e))) + + def toggle_crop_mode(self): + """Toggles the crop selection mode.""" + self.crop_mode = not self.crop_mode + self.canvas.crop_rect = QRect() + self.canvas.update() + + if self.crop_mode: + self.setCursor(Qt.CrossCursor) + self.sb_info_label.setText(f"{self.sb_info_label.text()} [CROP]") + else: + self.setCursor(Qt.ArrowCursor) + self.update_status_bar() + + def show_crop_menu(self, global_pos): + """Shows a context menu for the crop selection.""" + menu = QMenu(self) + save_action = menu.addAction(UITexts.VIEWER_MENU_SAVE_CROP) + cancel_action = menu.addAction(UITexts.CLOSE) + + res = menu.exec(global_pos) + if res == save_action: + self.save_cropped_image() + elif res == cancel_action: + self.canvas.crop_rect = QRect() + self.canvas.update() + + def save_cropped_image(self): + """Saves the area currently selected in crop mode as a new image.""" + if not self.crop_mode or self.canvas.crop_rect.isNull(): + return + + # Get normalized coordinates from the canvas rect + nx, ny, nw, nh = self.canvas.map_to_source(self.canvas.crop_rect) + + # Use original pixmap to extract high-quality crop + orig = self.controller.pixmap_original + if orig.isNull(): + return + + W, H = orig.width(), orig.height() + + # Convert normalized center/size back to top-left pixel coordinates + # nx, ny are center coordinates + x = int((nx - nw/2) * W) + y = int((ny - nh/2) * H) + w = int(nw * W) + h = int(nh * H) + + # Validate boundaries + x = max(0, x) + y = max(0, y) + w = min(w, W - x) + h = min(h, H - y) + + if w <= 0 or h <= 0: + return + + cropped = orig.copy(x, y, w, h) + + default_dir = os.path.dirname(self.controller.get_current_path()) + file_name, _ = QFileDialog.getSaveFileName( + self, UITexts.SAVE_CROP_TITLE, default_dir, UITexts.SAVE_CROP_FILTER) + + if file_name: + cropped.save(file_name) + # Optionally stay in crop mode or exit + self.canvas.crop_rect = QRect() + self.canvas.update() + + def update_title(self): + """Updates the window title with the current image name.""" + title = f"{VIEWER_LABEL} - {os.path.basename( + self.controller.get_current_path())}" + if self.slideshow_timer.isActive() or self.slideshow_reverse_timer.isActive(): + title += UITexts.VIEWER_TITLE_SLIDESHOW + if self.movie and self.movie.state() == QMovie.Paused: + title += UITexts.VIEWER_TITLE_PAUSED + self.setWindowTitle(title) + + def update_status_bar(self, metadata=None, index=None): + """ + Updates the status bar with image dimensions, zoom level, and tags + read from extended attributes. + """ + total = len(self.controller.image_list) + + # Use provided index if available, otherwise get from controller + current_idx = index if index is not None else self.controller.index + idx = current_idx + 1 if total > 0 else 0 + self.sb_index_label.setText(f"[{idx}/{total}]") + + if self.controller.pixmap_original.isNull(): + self.sb_info_label.setText("") + self.sb_tags_label.setText("") + return + + w = self.controller.pixmap_original.width() + h = self.controller.pixmap_original.height() + zoom = int(self.controller.zoom_factor * 100) + self.sb_info_label.setText(f"{w} x {h} px | {zoom}%") + + # Use tags from controller's internal state + display_tags = [t.strip().split('/')[-1] + for t in self.controller._current_tags if t.strip()] + self.sb_tags_label.setText(", ".join(display_tags)) + + @Slot(str, dict) + def on_metadata_changed(self, path, metadata=None): + """ + Slot to handle metadata changes from the controller. + Updates the status bar and notifies the main window to refresh its views. + """ + if self.controller.get_current_path() == path: + self.update_status_bar(metadata) + if self.main_win: + self.main_win.update_metadata_for_path(path, metadata) + + def restore_scroll(self, config): + """ + Applies the saved scrollbar positions from a layout configuration. + + Args: + config (dict): The layout configuration dictionary. + """ + self.scroll_area.horizontalScrollBar().setValue(config.get("scroll_x", 0)) + self.scroll_area.verticalScrollBar().setValue(config.get("scroll_y", 0)) + + def get_state(self): + """ + Captures the complete state of the viewer for saving to a layout. + + Returns: + dict: A dictionary containing geometry, zoom, rotation, scroll + positions, and the current image path. + """ + geo = self.geometry() + return { + "path": self.controller.get_current_path(), + "index": self.controller.index, + "geometry": { + "x": geo.x(), "y": geo.y(), "w": geo.width(), "h": geo.height() + }, + "zoom": self.controller.zoom_factor, + "rotation": self.controller.rotation, + "show_faces": self.controller.show_faces, + "flip_h": self.controller.flip_h, + "flip_v": self.controller.flip_v, + "scroll_x": self.scroll_area.horizontalScrollBar().value(), + "scroll_y": self.scroll_area.verticalScrollBar().value(), + "status_bar_visible": self.status_bar_container.isVisible(), + "filmstrip_visible": self.filmstrip.isVisible() + } + + def first_image(self): + """Navigates to the first image in the list.""" + self.controller.first() + self.index_changed.emit(self.controller.index) + self._is_persistent = False + self.load_and_fit_image() + + def last_image(self): + """Navigates to the last image in the list.""" + self.controller.last() + self.index_changed.emit(self.controller.index) + self._is_persistent = False + self.load_and_fit_image() + + def next_image(self): + """Navigates to the next image in the list (wraps around).""" + self.controller.next() + self.index_changed.emit(self.controller.index) + self._is_persistent = False + self.load_and_fit_image() + + def prev_image(self): + """Navigates to the previous image in the list (wraps around).""" + self.controller.prev() + self.index_changed.emit(self.controller.index) + self._is_persistent = False + self.load_and_fit_image() + + def toggle_slideshow(self): + """Starts or stops the automatic slideshow timer.""" + if self.slideshow_reverse_timer.isActive(): + self.slideshow_reverse_timer.stop() + + if self.slideshow_timer.isActive(): + self.slideshow_timer.stop() + else: + self.slideshow_timer.start() + self.update_view(resize_win=False) + + def toggle_slideshow_reverse(self): + """Starts or stops the automatic reverse slideshow timer.""" + if self.slideshow_timer.isActive(): + self.slideshow_timer.stop() + + if self.slideshow_reverse_timer.isActive(): + self.slideshow_reverse_timer.stop() + else: + self.slideshow_reverse_timer.start() + self.update_view(resize_win=False) + + def set_slideshow_interval(self): + """Opens a dialog to set the slideshow interval in seconds.""" + val, ok = QInputDialog.getInt(self, + UITexts.SLIDESHOW_INTERVAL_TITLE, + UITexts.SLIDESHOW_INTERVAL_TEXT, + self.slideshow_timer.interval() // 1000, 1, 3600) + if ok: + new_interval_ms = val * 1000 + self.slideshow_timer.setInterval(new_interval_ms) + self.slideshow_reverse_timer.setInterval(new_interval_ms) + if self.slideshow_timer.isActive(): + self.slideshow_timer.start() + if self.slideshow_reverse_timer.isActive(): + self.slideshow_reverse_timer.start() + + def toggle_fullscreen(self): + """Toggles the viewer window between fullscreen and normal states.""" + if self.isFullScreen(): + self.showNormal() + else: + self.showFullScreen() + + def close_or_exit_fullscreen(self): + """Closes the viewer or exits fullscreen if active.""" + if self.isFullScreen(): + self.toggle_fullscreen() + else: + self.close() + + def refresh_shortcuts(self): + """Re-loads shortcuts from the main window configuration.""" + self._setup_shortcuts() + + def toggle_fit_to_screen(self): + """ + Toggles between fitting the image to the window and 100% actual size. + """ + # If close to 100%, fit to window. Otherwise 100%. + if abs(self.controller.zoom_factor - 1.0) < 0.01: + self.fit_to_window() + else: + self.controller.zoom_factor = 1.0 + self.update_view(resize_win=False) + + def fit_to_window(self): + """ + Calculates the zoom factor required to make the image fit perfectly + within the current viewport dimensions. + """ + if self.controller.pixmap_original.isNull(): + return + + viewport = self.scroll_area.viewport() + w_avail = viewport.width() + h_avail = viewport.height() + + transform = QTransform().rotate(self.controller.rotation) + transformed_pixmap = self.controller.pixmap_original.transformed( + transform, Qt.SmoothTransformation) + img_w = transformed_pixmap.width() + img_h = transformed_pixmap.height() + + if img_w == 0 or img_h == 0: + return + + self.controller.zoom_factor = min(w_avail / img_w, h_avail / img_h) + self.update_view(resize_win=False) + + def _get_clicked_face(self, pos): + """Checks if a click position is inside any face bounding box.""" + for face in self.controller.faces: + rect = self.canvas.map_from_source(face) + if rect.contains(pos): + return face + return None + + def _show_face_context_menu(self, event): + """ + Shows a context menu for a clicked face region. + Returns True if a menu was shown, False otherwise. + """ + if not self.controller.show_faces: + return False + + pos = self.canvas.mapFromGlobal(event.globalPos()) + clicked_face = self._get_clicked_face(pos) + + if not clicked_face: + return False + + menu = QMenu(self) + action_del = menu.addAction(UITexts.DELETE_FACE) + action_ren = menu.addAction(UITexts.RENAME_FACE_TITLE) + res = menu.exec(event.globalPos()) + + if res == action_del: + face_name = clicked_face.get('name', '') + self.controller.remove_face(clicked_face) + if face_name: + has_other = any(f.get('name') == face_name + for f in self.controller.faces) + if not has_other: + self.controller.toggle_tag(face_name, False) + self.canvas.update() + elif res == action_ren: + self.rename_face(clicked_face) + return True + + def rename_face(self, face_to_rename): + """Opens a dialog to rename a specific face/area.""" + if not face_to_rename: + return + + region_type = face_to_rename.get('type', 'Face') + history_list = [] + if self.main_win: + if region_type == "Pet": + history_list = self.main_win.pet_names_history + elif region_type == "Object": + history_list = self.main_win.object_names_history + elif region_type == "Landmark": + history_list = self.main_win.landmark_names_history + else: # Face + history_list = self.main_win.face_names_history + + history = history_list if self.main_win else [] + current_name = face_to_rename.get('name', '') + + new_full_tag, updated_history, ok = FaceNameDialog.get_name( + self, history, current_name, main_win=self.main_win, + region_type=region_type, title=UITexts.RENAME_FACE_TITLE) + + if ok and new_full_tag and new_full_tag != current_name: + # Remove old tag if it's not used by other faces + if current_name: + has_other = any(f.get('name') == current_name + for f in self.controller.faces + if f is not face_to_rename) + if not has_other: + self.controller.toggle_tag(current_name, False) + + # Update face and history + face_to_rename['name'] = new_full_tag + if self.main_win: + if region_type == "Pet": + self.main_win.pet_names_history = updated_history + elif region_type == "Object": + self.main_win.object_names_history = updated_history + elif region_type == "Landmark": + self.main_win.landmark_names_history = updated_history + else: # Face + self.main_win.face_names_history = updated_history + + # Save changes and add new tag + self.controller.save_faces() + self.controller.toggle_tag(new_full_tag, True) + self.canvas.update() + + def toggle_main_window_visibility(self): + """Toggles the visibility of the main window.""" + if self.main_win: + self.main_win.toggle_visibility() + + def show_properties(self): + """Shows the properties dialog for the current image.""" + path = self.controller.get_current_path() + if path: + tags = self.controller._current_tags + rating = self.controller._current_rating + dlg = PropertiesDialog( + path, initial_tags=tags, initial_rating=rating, parent=self) + dlg.exec() + + def _create_viewer_context_menu(self): + """Builds and returns the general viewer context menu.""" + menu = QMenu(self) + + # Add "Open With" submenu + if self.main_win: + path = self.controller.get_current_path() + if path: + open_submenu = menu.addMenu(QIcon.fromTheme("document-open"), + UITexts.CONTEXT_MENU_OPEN) + self.main_win.populate_open_with_submenu(open_submenu, path) + menu.addSeparator() + + menu_items = [ + {"text": UITexts.VIEWER_MENU_TAGS, "action": "fast_tag", + "icon": "document-properties"}, + "separator", + {"text": UITexts.DETECT_FACES, "action": "detect_faces", + "icon": "edit-image-face-recognize"}, + "separator", + {"text": UITexts.DETECT_PETS, "action": "detect_pets", + "icon": "edit-image-face-recognize"}, + "separator", + {"text": UITexts.VIEWER_MENU_ROTATE, "icon": "transform-rotate", + "submenu": [ + {"text": UITexts.VIEWER_MENU_ROTATE_LEFT, + "action": "rotate_left", "icon": "object-rotate-left"}, + {"text": UITexts.VIEWER_MENU_ROTATE_RIGHT, + "action": "rotate_right", "icon": "object-rotate-right"} + ]}, + "separator", + {"text": UITexts.VIEWER_MENU_FLIP, "icon": "transform-flip", "submenu": [ + {"text": UITexts.VIEWER_MENU_FLIP_H, + "action": "flip_horizontal", + "icon": "object-flip-horizontal"}, + {"text": UITexts.VIEWER_MENU_FLIP_V, + "action": "flip_vertical", "icon": "object-flip-vertical"} + ]}, + "separator", + {"text": UITexts.VIEWER_MENU_RENAME, "action": "rename", + "icon": "edit-rename"}, + "separator", + {"text": UITexts.VIEWER_MENU_FIT_SCREEN, "slot": self.toggle_fit_to_screen, + "icon": "zoom-fit-best"}, + "separator", + {"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop", + "icon": "transform-crop", "checkable": True, + "checked": self.crop_mode}, + "separator", + ] + + if self.movie: + is_paused = self.movie.state() == QMovie.Paused_ + pause_text = (UITexts.VIEWER_MENU_RESUME_ANIMATION if is_paused + else UITexts.VIEWER_MENU_PAUSE_ANIMATION) + pause_icon = ("media-playback-start" if is_paused + else "media-playback-pause") + menu_items.append({"text": pause_text, "action": "toggle_animation", + "icon": pause_icon}) + + is_fwd_slideshow = self.slideshow_timer.isActive() + is_rev_slideshow = self.slideshow_reverse_timer.isActive() + + slideshow_submenu = [ + {"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd_slideshow + else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow", + "icon": "media-playback-stop" if is_fwd_slideshow + else "media-playback-start"}, + {"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev_slideshow + else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW, + "action": "slideshow_reverse", + "icon": "media-playback-stop" if is_rev_slideshow + else "media-seek-backward"}, + {"text": UITexts.VIEWER_MENU_SET_INTERVAL, + "slot": self.set_slideshow_interval, "icon": "preferences-system-time"} + ] + + menu_items.extend([ + {"text": UITexts.VIEWER_MENU_SLIDESHOW, "icon": "view-presentation", + "submenu": slideshow_submenu}, + "separator", + {"text": UITexts.SHOW_FACES, "action": "toggle_faces", + "icon": "edit-image-face-show", + "checkable": True, "checked": self.controller.show_faces}, + {"text": UITexts.VIEWER_MENU_SHOW_FILMSTRIP, + "action": "toggle_filmstrip", "icon": "view-filmstrip", "checkable": True, + "checked": self.filmstrip.isVisible()}, + {"text": UITexts.VIEWER_MENU_SHOW_STATUSBAR, + "action": "toggle_statusbar", "icon": "view-bottom-panel", + "checkable": True, + "checked": self.status_bar_container.isVisible()}, + "separator", + {"text": UITexts.VIEWER_MENU_EXIT_FULLSCREEN + if self.isFullScreen() else UITexts.VIEWER_MENU_ENTER_FULLSCREEN, + "action": "fullscreen", + "icon": "view-fullscreen" if not self.isFullScreen() else "view-restore"}, + "separator", + {"text": "Show/hide main window", + "action": "toggle_visibility", + "icon": "view-restore"}, + "separator", + {"text": UITexts.CONTEXT_MENU_PROPERTIES, "action": "properties", + "icon": "document-properties"} + ]) + + def build_actions(target_menu, items): + for item in items: + if item == "separator": + target_menu.addSeparator() + continue + + action_name = item.get("action") + display_text = item["text"] + + # Only add shortcut to final actions, not to submenus + if action_name and "submenu" not in item and \ + action_name in self.action_to_shortcut: + key, mods = self.action_to_shortcut[action_name] + # Handle both Qt.KeyboardModifier (enum) and Qt.KeyboardModifiers + # (flags) by ensuring we have an integer value for the modifier + # before the bitwise OR. + try: + mod_val = int(mods) + except TypeError: + mod_val = mods.value + seq = QKeySequence(mod_val | key) + shortcut_str = seq.toString(QKeySequence.NativeText) + if shortcut_str: + display_text += f"\t{shortcut_str}" + + icon = QIcon.fromTheme(item.get("icon", "")) + + if "submenu" in item: + submenu = target_menu.addMenu(icon, item["text"]) + build_actions(submenu, item["submenu"]) + else: + action = target_menu.addAction(icon, display_text) + slot = item.get("slot") + if action_name: + action.triggered.connect( + lambda checked=False, name=action_name: + self._execute_action(name)) + elif slot: + action.triggered.connect(slot) + + if item.get("checkable"): + action.setCheckable(True) + action.setChecked(item.get("checked", False)) + + build_actions(menu, menu_items) + + return menu + + def _show_viewer_context_menu(self, event): + """Creates and shows the general viewer context menu.""" + menu = self._create_viewer_context_menu() + menu.exec(event.globalPos()) + + def _calculate_iou(self, boxA, boxB): + """Calculates Intersection over Union for two face boxes.""" + # Convert from center-based (x,y,w,h) to corner-based (x1,y1,x2,y2) + boxA_x1 = boxA['x'] - boxA['w'] / 2 + boxA_y1 = boxA['y'] - boxA['h'] / 2 + boxA_x2 = boxA['x'] + boxA['w'] / 2 + boxA_y2 = boxA['y'] + boxA['h'] / 2 + + boxB_x1 = boxB['x'] - boxB['w'] / 2 + boxB_y1 = boxB['y'] - boxB['h'] / 2 + boxB_x2 = boxB['x'] + boxB['w'] / 2 + boxB_y2 = boxB['y'] + boxB['h'] / 2 + + # Determine the coordinates of the intersection rectangle + xA = max(boxA_x1, boxB_x1) + yA = max(boxA_y1, boxB_y1) + xB = min(boxA_x2, boxB_x2) + yB = min(boxA_y2, boxB_y2) + + # Compute the area of intersection + interArea = max(0, xB - xA) * max(0, yB - yA) + + # Compute the area of both bounding boxes + boxAArea = boxA['w'] * boxA['h'] + boxBArea = boxB['w'] * boxB['h'] + + # Compute the intersection over union + denominator = float(boxAArea + boxBArea - interArea) + iou = interArea / denominator if denominator > 0 else 0 + + return iou + + def toggle_flip_horizontal(self): + """Horizontally flips the image.""" + self.controller.toggle_flip_h() + self.update_view(resize_win=False) + + def toggle_flip_vertical(self): + """Vertically flips the image.""" + self.controller.toggle_flip_v() + self.update_view(resize_win=False) + + def contextMenuEvent(self, event): + """Shows a context menu with viewer options. + + If a face region is clicked while face display is active, it shows + a context menu for that face. Otherwise, it shows the general + viewer context menu. + + Args: + event (QContextMenuEvent): The context menu event. + """ + if self.crop_mode and not self.canvas.crop_rect.isNull(): + pos = self.canvas.mapFromGlobal(event.globalPos()) + if self.canvas.crop_rect.contains(pos): + self.show_crop_menu(event.globalPos()) + return + + if self._show_face_context_menu(event): + return # Face menu was shown and handled + + # If no face was clicked or faces are not shown, show the general menu + self._show_viewer_context_menu(event) + + def run_face_detection(self): + """Runs face detection on the current image.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + new_faces = self.controller.detect_faces() + finally: + QApplication.restoreOverrideCursor() + + if not new_faces: + return + + IOU_THRESHOLD = 0.7 # If IoU is > 70%, consider it the same face + added_count = 0 + for new_face in new_faces: + is_duplicate = False + for existing_face in self.controller.faces: + iou = self._calculate_iou(new_face, existing_face) + if iou > IOU_THRESHOLD: + is_duplicate = True + break + + if is_duplicate: + continue + + if not self.controller.show_faces: + self.toggle_faces() + + self.controller.faces.append(new_face) + self.canvas.update() + + w = self.canvas.width() + h = self.canvas.height() + self.scroll_area.ensureVisible(int(new_face.get('x', 0) * w), + int(new_face.get('y', 0) * h), 50, 50) + QApplication.processEvents() + + history = self.main_win.face_names_history if self.main_win else [] + full_tag, updated_history, ok = FaceNameDialog.get_name( + self, history, main_win=self.main_win) + + if ok and full_tag: + new_face['name'] = full_tag + self.controller.toggle_tag(full_tag, True) + if self.main_win: + self.main_win.face_names_history = updated_history + added_count += 1 + else: + # If user cancels, remove the face that was temporarily added + self.controller.faces.pop() + self.canvas.update() + + if added_count > 0: + self.controller.save_faces() + + def run_pet_detection(self): + """Runs pet detection on the current image.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + new_pets = self.controller.detect_pets() + finally: + QApplication.restoreOverrideCursor() + + if not new_pets: + return + + IOU_THRESHOLD = 0.7 + added_count = 0 + for new_pet in new_pets: + is_duplicate = False + for existing_face in self.controller.faces: + iou = self._calculate_iou(new_pet, existing_face) + if iou > IOU_THRESHOLD: + is_duplicate = True + break + + if is_duplicate: + continue + + if not self.controller.show_faces: + self.toggle_faces() + + self.controller.faces.append(new_pet) + self.canvas.update() + + w = self.canvas.width() + h = self.canvas.height() + self.scroll_area.ensureVisible(int(new_pet.get('x', 0) * w), + int(new_pet.get('y', 0) * h), 50, 50) + QApplication.processEvents() + + history = self.main_win.pet_names_history if self.main_win else [] + full_tag, updated_history, ok = FaceNameDialog.get_name( + self, history, main_win=self.main_win, region_type="Pet") + + if ok and full_tag: + new_pet['name'] = full_tag + self.controller.toggle_tag(full_tag, True) + if self.main_win: + self.main_win.pet_names_history = updated_history + added_count += 1 + else: + self.controller.faces.pop() + self.canvas.update() + + if added_count > 0: + self.controller.save_faces() + + def toggle_filmstrip(self): + """Shows or hides the filmstrip widget.""" + visible = not self.filmstrip.isVisible() + self.filmstrip.setVisible(visible) + if visible: + self.populate_filmstrip() + if self.main_win: + self.main_win.show_filmstrip = visible + self.main_win.save_config() + + def toggle_status_bar(self): + """Shows or hides the status bar widget.""" + visible = not self.status_bar_container.isVisible() + self.status_bar_container.setVisible(visible) + if self.main_win: + self.main_win.show_viewer_status_bar = visible + self.main_win.save_config() + + def toggle_faces(self): + """Toggles the display of face regions.""" + self.controller.show_faces = not self.controller.show_faces + if self.main_win: + self.main_win.show_faces = self.controller.show_faces + self.main_win.save_config() + self.canvas.update() + + def show_fast_tag_menu(self): + """Shows a context menu for quickly adding/removing tags.""" + self.fast_tag_manager.show_menu() + + def changeEvent(self, event): + """ + Handles window state changes to sync with the main view on activation. + """ + if event.type() == QEvent.ActivationChange and self.isActiveWindow(): + self.activated.emit() + elif event.type() == QEvent.WindowStateChange: + if self.windowState() & Qt.WindowFullScreen: + self.reset_inactivity_timer() + else: + self.hide_controls_timer.stop() + self.unsetCursor() + if self.main_win: + self.status_bar_container.setVisible( + self.main_win.show_viewer_status_bar) + super().changeEvent(event) + + def wheelEvent(self, event): + """ + Handles mouse wheel events for zooming (with Ctrl) or navigation. + + Args: + event (QWheelEvent): The mouse wheel event. + """ + self.reset_inactivity_timer() + if event.modifiers() & Qt.ControlModifier: + # Zoom with Ctrl + Wheel + if event.angleDelta().y() > 0: + self.controller.zoom_factor *= 1.1 + else: + self.controller.zoom_factor *= 0.9 + self.update_view(resize_win=True) + else: + # Navigate next/previous based on configurable speed + speed = APP_CONFIG.get("viewer_wheel_speed", VIEWER_WHEEL_SPEED_DEFAULT) + # A standard tick is 120. We define a threshold based on speed. + # Speed 1 (slowest) requires a full 120 delta. + # Speed 10 (fastest) requires 120/10 = 12 delta. + threshold = 120 / speed + + self._wheel_scroll_accumulator += event.angleDelta().y() + + # Process all accumulated delta + while abs(self._wheel_scroll_accumulator) >= threshold: + if self._wheel_scroll_accumulator < 0: + # Scrolled down -> next image + self.next_image() + self._wheel_scroll_accumulator += threshold + else: + # Scrolled up -> previous image + self.prev_image() + self._wheel_scroll_accumulator -= threshold + + # --- Keyboard Handling --- + def keyPressEvent(self, event): + """ + Handles key press events for navigation and other shortcuts. + + Args: + event (QKeyEvent): The key press event. + """ + self.reset_inactivity_timer() + key_code = event.key() + modifiers = event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier | + Qt.AltModifier | Qt.MetaModifier) + + key_combo = (key_code, modifiers) + action = self.shortcuts.get(key_combo) + + if action: + self._execute_action(action) + event.accept() + else: + super().keyPressEvent(event) + + # --- Delete Management --- + def refresh_after_delete(self, new_list, deleted_idx=-1): + """ + Refreshes the viewer after an image has been deleted from the main window. + + Args: + new_list (list): The updated list of image paths. + deleted_idx (int): The index of the deleted image in the old list. + """ + self.controller.update_list(new_list) + if not self.controller.image_list: + self.close() + return + + if 0 <= deleted_idx < self.filmstrip.count(): + item = self.filmstrip.takeItem(deleted_idx) + del item # Ensure the QListWidgetItem is deleted + else: + self.populate_filmstrip() # Fallback to full rebuild + + # Reload image in case the current one was deleted or index changed + self.load_and_fit_image() + + # Notify the main window that the image (and possibly index) has changed + # so it can update its selection. + self.index_changed.emit(self.controller.index) + + # --- Window Close --- + def closeEvent(self, event): + """ + Handles the window close event. + + Ensures the screensaver is uninhibited and checks if the application + should exit if it's the last open viewer. + + Args: + event (QCloseEvent): The close event. + """ + if self.movie: + self.movie.stop() + self.slideshow_timer.stop() + self.slideshow_reverse_timer.stop() + if self.filmstrip_loader and self.filmstrip_loader.isRunning(): + self.filmstrip_loader.stop() + self.uninhibit_screensaver() + self.controller.cleanup() + # If we close the last viewer and the main window is hidden, quit. + if self.main_win and not self.main_win.isVisible(): + # Check how many viewers are left + viewers = [w for w in QApplication.topLevelWidgets() if isinstance( + w, ImageViewer) and w.isVisible()] + # 'viewers' includes 'self' as it's not fully destroyed yet + if len(viewers) <= 1: + self.main_win.perform_shutdown() + QApplication.quit() + + def set_window_icon(self): + """Sets the window icon from the current theme.""" + icon = QIcon.fromTheme(ICON_THEME_VIEWER, + QIcon.fromTheme(ICON_THEME_VIEWER_FALLBACK)) + self.setWindowIcon(icon) + + # --- DBus Inhibition --- + def inhibit_screensaver(self): + """ + Prevents the screensaver or power management from activating. + + Uses DBus to send an inhibit request to the session's screen saver + service, which is common on Linux desktops. + """ + try: + cmd = [ + "dbus-send", "--session", "--print-reply", + "--dest=org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver.Inhibit", + "string:bagheeraview", # Application name + "string:Viewing images" # Reason for inhibition + ] + output = subprocess.check_output(cmd, text=True) + # Extract the cookie from the output (e.g., "uint32 12345") + self.inhibit_cookie = int(output.split()[-1]) + except Exception as e: + print(f"{UITexts.ERROR} inhibiting power management: {e}") + self.inhibit_cookie = None + + def uninhibit_screensaver(self): + """ + Releases the screensaver inhibit lock. + + Uses DBus to uninhibit the screensaver using the cookie obtained + during the inhibit call. + """ + if hasattr(self, 'inhibit_cookie') and self.inhibit_cookie is not None: + try: + subprocess.Popen([ + "dbus-send", "--session", + "--dest=org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver.UnInhibit", + f"uint32:{self.inhibit_cookie}" + ]) + self.inhibit_cookie = None + except Exception as e: + print(f"{UITexts.ERROR} uninhibiting: {e}") diff --git a/metadatamanager.py b/metadatamanager.py new file mode 100644 index 0000000..5bf9b72 --- /dev/null +++ b/metadatamanager.py @@ -0,0 +1,138 @@ +""" +Metadata Manager Module for Bagheera. + +This module provides a dedicated class for handling various metadata formats +like EXIF, IPTC, and XMP, using the exiv2 library. + +Classes: + MetadataManager: A class with static methods to read metadata from files. +""" +import os +from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus +try: + import exiv2 + HAVE_EXIV2 = True +except ImportError: + exiv2 = None + HAVE_EXIV2 = False +from utils import preserve_mtime + + +def notify_baloo(path): + """ + Notifies the Baloo file indexer about a file change using DBus. + + This is an asynchronous, non-blocking call. It's more efficient than + calling `balooctl` via subprocess. + + Args: + path (str): The absolute path of the file that was modified. + """ + if not path: + return + + # Use QDBusMessage directly for robust calling + msg = QDBusMessage.createMethodCall( + "org.kde.baloo.file", "/org/kde/baloo/file", + "org.kde.baloo.file.indexer", "indexFile" + ) + msg.setArguments([path]) + QDBusConnection.sessionBus().call(msg, QDBus.NoBlock) + + +class MetadataManager: + """Manages reading EXIF, IPTC, and XMP metadata.""" + + @staticmethod + def read_all_metadata(path): + """ + Reads all available EXIF, IPTC, and XMP metadata from a file. + + Args: + path (str): The path to the image file. + + Returns: + dict: A dictionary containing all found metadata key-value pairs. + Returns an empty dictionary if exiv2 is not available or on error. + """ + if not HAVE_EXIV2: + return {} + + all_metadata = {} + try: + image = exiv2.ImageFactory.open(path) + image.readMetadata() + + # EXIF + for datum in image.exifData(): + if datum.toString(): + all_metadata[datum.key()] = datum.toString() + + # IPTC + for datum in image.iptcData(): + if datum.toString(): + all_metadata[datum.key()] = datum.toString() + + # XMP + for datum in image.xmpData(): + if datum.toString(): + all_metadata[datum.key()] = datum.toString() + + except Exception as e: + print(f"Error reading metadata for {path}: {e}") + + return all_metadata + + +class XattrManager: + """A manager class to handle reading and writing extended attributes (xattrs).""" + @staticmethod + def get_attribute(path_or_fd, attr_name, default_value=""): + """ + Gets a string value from a file's extended attribute. This is a disk read. + + Args: + path_or_fd (str or int): The path to the file or a file descriptor. + attr_name (str): The name of the extended attribute. + default_value (any): The value to return if the attribute is not found. + + Returns: + str: The attribute value or the default value. + """ + if path_or_fd is None or path_or_fd == "": + return default_value + try: + return os.getxattr(path_or_fd, attr_name).decode('utf-8') + except (OSError, AttributeError): + return default_value + + @staticmethod + def set_attribute(file_path, attr_name, value): + """ + Sets a string value for a file's extended attribute. + + If the value is None or an empty string, the attribute is removed. + + Args: + file_path (str): The path to the file. + attr_name (str): The name of the extended attribute. + value (str or None): The value to set. + + Raises: + IOError: If the attribute could not be saved. + """ + if not file_path: + return + try: + with preserve_mtime(file_path): + if value: + os.setxattr(file_path, attr_name, str(value).encode('utf-8')) + else: + try: + os.removexattr(file_path, attr_name) + except OSError: + pass + notify_baloo(file_path) + except Exception as e: + raise IOError(f"Could not save xattr '{attr_name}' " + "for {file_path}: {e}") from e diff --git a/propertiesdialog.py b/propertiesdialog.py new file mode 100644 index 0000000..3fd9017 --- /dev/null +++ b/propertiesdialog.py @@ -0,0 +1,403 @@ +""" +Properties Dialog Module for Bagheera Image Viewer. + +This module provides the properties dialog for the application, which displays +detailed information about an image file across several tabs: general file +info, editable metadata (extended attributes), and EXIF/XMP/IPTC data. + +Classes: + PropertiesDialog: A QDialog that presents file properties in a tabbed + interface. +""" +import os +from PySide6.QtWidgets import ( + QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, + QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, + QFormLayout, QDialogButtonBox, QApplication +) +from PySide6.QtGui import ( + QImageReader, QIcon, QColor +) +from PySide6.QtCore import ( + Qt, QFileInfo, QLocale +) +from constants import ( + RATING_XATTR_NAME, XATTR_NAME, UITexts +) +from metadatamanager import MetadataManager, HAVE_EXIV2, notify_baloo +from utils import preserve_mtime + + +class PropertiesDialog(QDialog): + """ + A dialog window to display detailed properties of an image file. + + This dialog features multiple tabs: + - General: Basic file information (size, dates, dimensions). This involves os.stat + and QImageReader. + - Metadata: Editable key-value pairs, primarily for extended attributes (xattrs). + - EXIF: Detailed EXIF, IPTC, and XMP metadata, loaded via the exiv2 library. + """ + def __init__(self, path, initial_tags=None, initial_rating=0, parent=None): + """ + Initializes the PropertiesDialog. + + Args: + path (str): The absolute path to the image file. + parent (QWidget, optional): The parent widget. Defaults to None. + """ + super().__init__(parent) + self.path = path + self.setWindowTitle(UITexts.PROPERTIES_TITLE) + self._initial_tags = initial_tags if initial_tags is not None else [] + self._initial_rating = initial_rating + self.resize(400, 500) + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + + layout = QVBoxLayout(self) + tabs = QTabWidget() + layout.addWidget(tabs) + + # --- General Tab --- + general_widget = QWidget() + form_layout = QFormLayout(general_widget) + form_layout.setLabelAlignment(Qt.AlignRight) + form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop) + form_layout.setContentsMargins(20, 20, 20, 20) + form_layout.setSpacing(10) + + info = QFileInfo(path) + reader = QImageReader(path) + reader.setAutoTransform(True) + + # Basic info + form_layout.addRow(UITexts.PROPERTIES_FILENAME, QLabel(info.fileName())) + form_layout.addRow(UITexts.PROPERTIES_LOCATION, QLabel(info.path())) + form_layout.addRow(UITexts.PROPERTIES_SIZE, + QLabel(self.format_size(info.size()))) + + # Dates + form_layout.addRow(UITexts.PROPERTIES_CREATED, + QLabel(QLocale.system().toString(info.birthTime(), + QLocale.ShortFormat))) + form_layout.addRow(UITexts.PROPERTIES_MODIFIED, + QLabel(QLocale.system().toString(info.lastModified(), + QLocale.ShortFormat))) + + # Image info + size = reader.size() + fmt = reader.format().data().decode('utf-8').upper() + if size.isValid(): + form_layout.addRow(UITexts.PROPERTIES_DIMENSIONS, + QLabel(f"{size.width()} x {size.height()} px")) + megapixels = (size.width() * size.height()) / 1_000_000 + form_layout.addRow(UITexts.PROPERTIES_MEGAPIXELS, + QLabel(f"{megapixels:.2f} MP")) + + # Read image to get depth + img = reader.read() + if not img.isNull(): + form_layout.addRow(UITexts.PROPERTIES_COLOR_DEPTH, + QLabel(f"{img.depth()} {UITexts.BITS}")) + + if fmt: + form_layout.addRow(UITexts.PROPERTIES_FORMAT, QLabel(fmt)) + + tabs.addTab(general_widget, QIcon.fromTheme("dialog-information"), + UITexts.PROPERTIES_GENERAL_TAB) + + # --- Metadata Tab --- + meta_widget = QWidget() + meta_layout = QVBoxLayout(meta_widget) + + self.table = QTableWidget() + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive) + self.table.horizontalHeader().setSectionResizeMode(1, + QHeaderView.ResizeToContents) + self.table.setColumnWidth(0, self.width() * 0.4) + self.table.verticalHeader().setVisible(False) + self.table.setAlternatingRowColors(True) + self.table.setEditTriggers(QTableWidget.DoubleClicked | + QTableWidget.EditKeyPressed | + QTableWidget.SelectedClicked) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + + self.table.itemChanged.connect(self.on_item_changed) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self.show_context_menu) + + self.load_metadata() + meta_layout.addWidget(self.table) + tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"), + UITexts.PROPERTIES_METADATA_TAB) + + # --- EXIF Tab --- + exif_widget = QWidget() + exif_layout = QVBoxLayout(exif_widget) + + self.exif_table = QTableWidget() + # This table will display EXIF/XMP/IPTC data. + # Reading this data involves opening the file with exiv2, which is a disk read. + # This is generally acceptable for a properties dialog, as it's an explicit + # user request for detailed information. Caching all possible EXIF data + # for every image might be too memory intensive if not frequently accessed. + # Therefore, this disk read is considered necessary and not easily optimizable + # without a significant architectural change (e.g., a dedicated metadata DB). + self.exif_table.setColumnCount(2) + self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) + self.exif_table.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.ResizeToContents) + self.exif_table.horizontalHeader().setSectionResizeMode( + 1, QHeaderView.ResizeToContents) + self.exif_table.verticalHeader().setVisible(False) + self.exif_table.setAlternatingRowColors(True) + self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.exif_table.setSelectionBehavior(QTableWidget.SelectRows) + self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu) + # This is a disk read. + self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) + + self.load_exif_data() + + exif_layout.addWidget(self.exif_table) + tabs.addTab(exif_widget, QIcon.fromTheme("view-details"), + UITexts.PROPERTIES_EXIF_TAB) + + # Buttons + btn_box = QDialogButtonBox(QDialogButtonBox.Close) + close_button = btn_box.button(QDialogButtonBox.Close) + if close_button: + close_button.setIcon(QIcon.fromTheme("window-close")) + btn_box.rejected.connect(self.close) + layout.addWidget(btn_box) + + def load_metadata(self): + """ + Loads metadata from the file's text keys (via QImageReader) and + extended attributes (xattrs) into the metadata table. + """ + self.table.blockSignals(True) + self.table.setRowCount(0) + + # Use pre-loaded tags and rating if available + preloaded_xattrs = {} + if self._initial_tags: + preloaded_xattrs[XATTR_NAME] = ", ".join(self._initial_tags) + if self._initial_rating > 0: + preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating) + + # Read other xattrs from disk + xattrs = {} + try: + for xkey in os.listxattr(self.path): + # Avoid re-reading already known attributes + if xkey not in preloaded_xattrs: + try: + val = os.getxattr(self.path, xkey) # This is a disk read + try: + val_str = val.decode('utf-8') + except UnicodeDecodeError: + val_str = str(val) + xattrs[xkey] = val_str + except Exception: + pass + except Exception: + pass + + # Combine preloaded and newly read xattrs + all_xattrs = {**preloaded_xattrs, **xattrs} + + self.table.setRowCount(len(all_xattrs)) + + row = 0 + # Display all xattrs + for key, val in all_xattrs.items(): + # QImageReader.textKeys() is not used here as it's not xattr. + k_item = QTableWidgetItem(key) + k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + v_item = QTableWidgetItem(val) + v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row, 0, k_item) + self.table.setItem(row, 1, v_item) + row += 1 + self.table.blockSignals(False) + + def load_exif_data(self): + """Loads EXIF, XMP, and IPTC metadata using the MetadataManager.""" + self.exif_table.blockSignals(True) + self.exif_table.setRowCount(0) + + if not HAVE_EXIV2: + self.exif_table.setRowCount(1) + error_color = QColor("red") + item = QTableWidgetItem(UITexts.ERROR) + item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + item.setForeground(error_color) + self.exif_table.setItem(0, 0, item) + msg_item = QTableWidgetItem(UITexts.EXIV2_NOT_INSTALLED) + msg_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + msg_item.setForeground(error_color) + self.exif_table.setItem(0, 1, msg_item) + self.exif_table.blockSignals(False) + return + + exif_data = MetadataManager.read_all_metadata(self.path) + + if not exif_data: + self.exif_table.setRowCount(1) + item = QTableWidgetItem(UITexts.INFO) + item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.exif_table.setItem(0, 0, item) + msg_item = QTableWidgetItem(UITexts.NO_METADATA_FOUND) + msg_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.exif_table.setItem(0, 1, msg_item) + self.exif_table.blockSignals(False) + return + + self.exif_table.setRowCount(len(exif_data)) + error_color = QColor("red") + error_text_lower = UITexts.ERROR.lower() + warning_text_lower = UITexts.WARNING.lower() + + for row, (key, value) in enumerate(sorted(exif_data.items())): + k_item = QTableWidgetItem(str(key)) + k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + v_item = QTableWidgetItem(str(value)) + v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + + key_str_lower = str(key).lower() + val_str_lower = str(value).lower() + if (error_text_lower in key_str_lower or warning_text_lower + in key_str_lower or + error_text_lower in val_str_lower + or warning_text_lower in val_str_lower): + k_item.setForeground(error_color) + v_item.setForeground(error_color) + + self.exif_table.setItem(row, 0, k_item) + self.exif_table.setItem(row, 1, v_item) + + self.exif_table.blockSignals(False) + + def on_item_changed(self, item): + """ + Slot that triggers when an item in the metadata table is changed. + + Args: + item (QTableWidgetItem): The item that was changed. + """ + if item.column() == 1: + key = self.table.item(item.row(), 0).text() + val = item.text() + try: + with preserve_mtime(self.path): + if not val.strip(): + try: + os.removexattr(self.path, key) + except OSError: + pass + else: + os.setxattr(self.path, key, val.encode('utf-8')) + notify_baloo(self.path) + except Exception as e: + QMessageBox.warning(self, UITexts.ERROR, + UITexts.PROPERTIES_ERROR_SET_ATTR.format(e)) + + def show_context_menu(self, pos): + """ + Displays a context menu in the metadata table. + + Args: + pos (QPoint): The position where the context menu was requested. + """ + menu = QMenu() + add_action = menu.addAction(QIcon.fromTheme("list-add"), + UITexts.PROPERTIES_ADD_ATTR) + + item = self.table.itemAt(pos) + copy_action = None + delete_action = None + + if item: + copy_action = menu.addAction(QIcon.fromTheme("edit-copy"), + UITexts.COPY) + val_item = self.table.item(item.row(), 1) + if val_item.flags() & Qt.ItemIsEditable: + delete_action = menu.addAction(QIcon.fromTheme("list-remove"), + UITexts.PROPERTIES_DELETE_ATTR) + + action = menu.exec(self.table.mapToGlobal(pos)) + if action == add_action: + self.add_attribute() + elif copy_action and action == copy_action: + val = self.table.item(item.row(), 1).text() + QApplication.clipboard().setText(val) + elif delete_action and action == delete_action: + self.delete_attribute(item.row()) + + def show_exif_context_menu(self, pos): + """Displays a context menu in the EXIF table (Copy only).""" + menu = QMenu() + item = self.exif_table.itemAt(pos) + if item: + copy_action = menu.addAction(QIcon.fromTheme("edit-copy"), UITexts.COPY) + action = menu.exec(self.exif_table.mapToGlobal(pos)) + if action == copy_action: + val = self.exif_table.item(item.row(), 1).text() + QApplication.clipboard().setText(val) + + def add_attribute(self): + """ + Opens dialogs to get a key and value for a new extended attribute and applies + it. + """ + key, ok = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR, + UITexts.PROPERTIES_ADD_ATTR_NAME) + if ok and key: + val, ok2 = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR, + UITexts.PROPERTIES_ADD_ATTR_VALUE.format( + key)) + if ok2: + try: + with preserve_mtime(self.path): + os.setxattr(self.path, key, val.encode('utf-8')) + notify_baloo(self.path) + self.load_metadata() + except Exception as e: + QMessageBox.warning(self, UITexts.ERROR, + UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e)) + + def delete_attribute(self, row): + """ + Deletes the extended attribute corresponding to the given table row. + + Args: + row (int): The row index of the attribute to delete. + """ + key = self.table.item(row, 0).text() + try: + with preserve_mtime(self.path): + os.removexattr(self.path, key) + notify_baloo(self.path) + self.table.removeRow(row) + except Exception as e: + QMessageBox.warning(self, UITexts.ERROR, + UITexts.PROPERTIES_ERROR_DELETE_ATTR.format(e)) + + def format_size(self, size): + """ + Formats a size in bytes into a human-readable string (B, KiB, MiB, etc.). + + Args: + size (int): The size in bytes. + + Returns: + str: The formatted size string. + """ + for unit in ['B', 'KiB', 'MiB', 'GiB']: + if size < 1024: + return f"{size:.2f} {unit}" + size /= 1024 + return f"{size:.2f} TiB" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2fb0c0a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bagheeraview" +version = "0.9.11" +authors = [ + { name = "Ignacio Serantes" } +] +description = "Bagheera Image Viewer - An image viewer for KDE with Baloo in mind" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT License" } +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Multimedia :: Graphics :: Viewers", + "Intended Audience :: End Users/Desktop", +] +dependencies = [ + "PySide6", + "lmdb", + "exiv2", + "mediapipe", + "face_recognition", + "face_recognition_models", + "setuptools==80.0.0", +] + +[project.optional-dependencies] +faces = [ + "face-recognition", + "face_recognition_models", + "mediapipe" +] +exiv = [ + "exiv2" +] + +[project.scripts] +bagheeraview = "bagheeraview:main" + +[tool.setuptools] +packages = { find = {} } +py-modules = [ + "bagheeraview", + "constants", + "settings", + "imagescanner", + "imageviewer", + "imagecontroller", + "metadatamanager", + "propertiesdialog", + "thumbnailwidget", + "widgets", + "xmpmanager", + "utils" +] +zip-safe = false diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb3c416 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +PySide6 +lmdb +exiv2 +mediapipe +face_recognition +face_recognition_models +setuptools==80.0.0 diff --git a/settings.py b/settings.py new file mode 100644 index 0000000..2190b05 --- /dev/null +++ b/settings.py @@ -0,0 +1,1014 @@ +""" +Settings Module for Bagheera Image Viewer. + +This module provides the main configuration dialog for the application, +allowing users to customize various aspects of its behavior, such as scanner +settings, face detection options, and thumbnail appearance. + +Classes: + ModelDownloader: A QThread worker for downloading the MediaPipe model file. + SettingsDialog: The main QDialog that presents all configurable options + in a tabbed interface. +""" +import os +import shutil +import urllib.request + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QColor, QIcon, QFont +from PySide6.QtWidgets import ( + QCheckBox, QColorDialog, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, + QLabel, QLineEdit, QMessageBox, QProgressDialog, QPushButton, QSpinBox, + QTabWidget, QVBoxLayout, QWidget +) +from constants import ( + APP_CONFIG, AVAILABLE_FACE_ENGINES, DEFAULT_FACE_BOX_COLOR, + DEFAULT_PET_BOX_COLOR, DEFAULT_OBJECT_BOX_COLOR, DEFAULT_LANDMARK_BOX_COLOR, + FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, + AVAILABLE_PET_ENGINES, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, + SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT, + THUMBNAILS_FILENAME_LINES_DEFAULT, + THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, + THUMBNAILS_FILENAME_COLOR_DEFAULT, THUMBNAILS_TAGS_COLOR_DEFAULT, + THUMBNAILS_RATING_COLOR_DEFAULT, THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT, + THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, + THUMBNAILS_TAGS_LINES_DEFAULT, THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, + VIEWER_AUTO_RESIZE_WINDOW_DEFAULT, VIEWER_WHEEL_SPEED_DEFAULT, + UITexts, save_app_config, +) + + +class ModelDownloader(QThread): + """A thread to download the MediaPipe model file without freezing the UI.""" + download_complete = Signal(bool, str) # success (bool), message (str) + + def __init__(self, url, dest_path, parent=None): + super().__init__(parent) + self.url = url + self.dest_path = dest_path + + def run(self): + try: + os.makedirs(os.path.dirname(self.dest_path), exist_ok=True) + with urllib.request.urlopen(self.url) as response, \ + open(self.dest_path, 'wb') as out_file: + shutil.copyfileobj(response, out_file) + self.download_complete.emit(True, "") + except Exception as e: + if os.path.exists(self.dest_path): + os.remove(self.dest_path) + self.download_complete.emit(False, str(e)) + + +class SettingsDialog(QDialog): + """A dialog to configure application settings.""" + + def __init__(self, parent=None): + """Initializes the settings dialog window. + + This sets up the tabbed interface and all the individual configuration + widgets for scanner, faces, thumbnails, and viewer settings. + + Args: + parent (QWidget, optional): The parent widget. Defaults to None. + """ + + super().__init__(parent) + self.setWindowTitle(UITexts.MENU_SETTINGS) + self.setMinimumWidth(500) + self.scan_max_level_min = 0 + self.scan_max_level_max = 10 + + self.current_face_color = DEFAULT_FACE_BOX_COLOR + self.current_pet_color = DEFAULT_PET_BOX_COLOR + self.current_object_color = DEFAULT_OBJECT_BOX_COLOR + self.current_landmark_color = DEFAULT_LANDMARK_BOX_COLOR + self.current_thumbs_bg_color = THUMBNAILS_BG_COLOR_DEFAULT + self.current_thumbs_filename_color = THUMBNAILS_FILENAME_COLOR_DEFAULT + self.current_thumbs_tags_color = THUMBNAILS_TAGS_COLOR_DEFAULT + self.current_thumbs_rating_color = THUMBNAILS_RATING_COLOR_DEFAULT + self.current_thumbs_tooltip_bg_color = THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT + self.current_thumbs_tooltip_fg_color = THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT + self.downloader_thread = None + + layout = QVBoxLayout(self) + + tabs = QTabWidget() + layout.addWidget(tabs) + + # --- Create all tabs and layouts first --- + thumbs_tab = QWidget() + thumbs_layout = QVBoxLayout(thumbs_tab) + + viewer_tab = QWidget() + viewer_layout = QVBoxLayout(viewer_tab) + + faces_tab = QWidget() + faces_layout = QVBoxLayout(faces_tab) + + scanner_tab = QWidget() + scanner_layout = QVBoxLayout(scanner_tab) + + # --- Thumbnails Tab --- + + mru_tags_layout = QHBoxLayout() + self.mru_tags_spin = QSpinBox() + self.mru_tags_spin.setRange(5, 100) + mru_label = QLabel(UITexts.SETTINGS_MRU_TAGS_COUNT_LABEL) + mru_tags_layout.addWidget(mru_label) + mru_tags_layout.addWidget(self.mru_tags_spin) + mru_label.setToolTip(UITexts.SETTINGS_MRU_TAGS_TOOLTIP) + self.mru_tags_spin.setToolTip(UITexts.SETTINGS_MRU_TAGS_TOOLTIP) + thumbs_layout.addLayout(mru_tags_layout) + + thumbs_refresh_layout = QHBoxLayout() + self.thumbs_refresh_spin = QSpinBox() + self.thumbs_refresh_spin.setRange(50, 1000) + self.thumbs_refresh_spin.setSingleStep(10) + self.thumbs_refresh_spin.setSuffix(" ms") + thumbs_refresh_label = QLabel(UITexts.SETTINGS_THUMBS_REFRESH_LABEL) + thumbs_refresh_layout.addWidget(thumbs_refresh_label) + thumbs_refresh_layout.addWidget(self.thumbs_refresh_spin) + thumbs_refresh_label.setToolTip(UITexts.SETTINGS_THUMBS_REFRESH_TOOLTIP) + self.thumbs_refresh_spin.setToolTip(UITexts.SETTINGS_THUMBS_REFRESH_TOOLTIP) + thumbs_layout.addLayout(thumbs_refresh_layout) + + thumbs_bg_color_layout = QHBoxLayout() + thumbs_bg_color_label = QLabel(UITexts.SETTINGS_THUMBS_BG_COLOR_LABEL) + self.thumbs_bg_color_btn = QPushButton() + self.thumbs_bg_color_btn.clicked.connect(self.choose_thumbs_bg_color) + thumbs_bg_color_layout.addWidget(thumbs_bg_color_label) + thumbs_bg_color_layout.addWidget(self.thumbs_bg_color_btn) + thumbs_bg_color_label.setToolTip(UITexts.SETTINGS_THUMBS_BG_COLOR_TOOLTIP) + self.thumbs_bg_color_btn.setToolTip(UITexts.SETTINGS_THUMBS_BG_COLOR_TOOLTIP) + thumbs_layout.addLayout(thumbs_bg_color_layout) + + thumbs_filename_color_layout = QHBoxLayout() + thumbs_filename_color_label = QLabel( + UITexts.SETTINGS_THUMBS_FILENAME_COLOR_LABEL) + self.thumbs_filename_color_btn = QPushButton() + self.thumbs_filename_color_btn.clicked.connect( + self.choose_thumbs_filename_color) + thumbs_filename_color_layout.addWidget(thumbs_filename_color_label) + thumbs_filename_color_layout.addWidget(self.thumbs_filename_color_btn) + thumbs_filename_color_label.setToolTip( + UITexts.SETTINGS_THUMBS_FILENAME_COLOR_TOOLTIP) + self.thumbs_filename_color_btn.setToolTip( + UITexts.SETTINGS_THUMBS_FILENAME_COLOR_TOOLTIP) + thumbs_layout.addLayout(thumbs_filename_color_layout) + + thumbs_tags_color_layout = QHBoxLayout() + thumbs_tags_color_label = QLabel(UITexts.SETTINGS_THUMBS_TAGS_COLOR_LABEL) + self.thumbs_tags_color_btn = QPushButton() + self.thumbs_tags_color_btn.clicked.connect(self.choose_thumbs_tags_color) + thumbs_tags_color_layout.addWidget(thumbs_tags_color_label) + thumbs_tags_color_layout.addWidget(self.thumbs_tags_color_btn) + thumbs_tags_color_label.setToolTip(UITexts.SETTINGS_THUMBS_TAGS_COLOR_TOOLTIP) + self.thumbs_tags_color_btn.setToolTip( + UITexts.SETTINGS_THUMBS_TAGS_COLOR_TOOLTIP) + thumbs_layout.addLayout(thumbs_tags_color_layout) + + thumbs_rating_color_layout = QHBoxLayout() + thumbs_rating_color_label = QLabel(UITexts.SETTINGS_THUMBS_RATING_COLOR_LABEL) + self.thumbs_rating_color_btn = QPushButton() + self.thumbs_rating_color_btn.clicked.connect(self.choose_thumbs_rating_color) + thumbs_rating_color_layout.addWidget(thumbs_rating_color_label) + thumbs_rating_color_layout.addWidget(self.thumbs_rating_color_btn) + thumbs_rating_color_label.setToolTip( + UITexts.SETTINGS_THUMBS_RATING_COLOR_TOOLTIP) + self.thumbs_rating_color_btn.setToolTip( + UITexts.SETTINGS_THUMBS_RATING_COLOR_TOOLTIP) + thumbs_layout.addLayout(thumbs_rating_color_layout) + + filename_font_size_layout = QHBoxLayout() + self.filename_font_size_spin = QSpinBox() + self.filename_font_size_spin.setRange(6, 16) + self.filename_font_size_spin.setSuffix(" pt") + filename_font_label = QLabel(UITexts.SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL) + filename_font_size_layout.addWidget(filename_font_label) + filename_font_size_layout.addWidget(self.filename_font_size_spin) + filename_font_label.setToolTip( + UITexts.SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP) + self.filename_font_size_spin.setToolTip( + UITexts.SETTINGS_THUMBS_FILENAME_FONT_SIZE_TOOLTIP) + thumbs_layout.addLayout(filename_font_size_layout) + + tags_font_size_layout = QHBoxLayout() + self.tags_font_size_spin = QSpinBox() + self.tags_font_size_spin.setRange(6, 16) + self.tags_font_size_spin.setSuffix(" pt") + tags_font_label = QLabel(UITexts.SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL) + tags_font_size_layout.addWidget(tags_font_label) + tags_font_size_layout.addWidget(self.tags_font_size_spin) + tags_font_label.setToolTip(UITexts.SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP) + self.tags_font_size_spin.setToolTip( + UITexts.SETTINGS_THUMBS_TAGS_FONT_SIZE_TOOLTIP) + thumbs_layout.addLayout(tags_font_size_layout) + + # --- Thumbs Tooltip Background Color --- + thumbs_tooltip_bg_color_layout = QHBoxLayout() + thumbs_tooltip_bg_color_label = QLabel( + UITexts.SETTINGS_THUMBS_TOOLTIP_BG_COLOR_LABEL) + self.thumbs_tooltip_bg_color_btn = QPushButton() + self.thumbs_tooltip_bg_color_btn.clicked.connect( + self.choose_thumbs_tooltip_bg_color) + thumbs_tooltip_bg_color_layout.addWidget(thumbs_tooltip_bg_color_label) + thumbs_tooltip_bg_color_layout.addWidget(self.thumbs_tooltip_bg_color_btn) + thumbs_tooltip_bg_color_label.setToolTip( + UITexts.SETTINGS_THUMBS_TOOLTIP_BG_COLOR_TOOLTIP) + self.thumbs_tooltip_bg_color_btn.setToolTip( + UITexts.SETTINGS_THUMBS_TOOLTIP_BG_COLOR_TOOLTIP) + thumbs_layout.addLayout(thumbs_tooltip_bg_color_layout) + + # --- Thumbs Tooltip Foreground Color --- + thumbs_tooltip_fg_color_layout = QHBoxLayout() + thumbs_tooltip_fg_color_label = QLabel( + UITexts.SETTINGS_THUMBS_TOOLTIP_FG_COLOR_LABEL) + self.thumbs_tooltip_fg_color_btn = QPushButton() + self.thumbs_tooltip_fg_color_btn.clicked.connect( + self.choose_thumbs_tooltip_fg_color) + thumbs_tooltip_fg_color_layout.addWidget(thumbs_tooltip_fg_color_label) + thumbs_tooltip_fg_color_layout.addWidget(self.thumbs_tooltip_fg_color_btn) + thumbs_tooltip_fg_color_label.setToolTip( + UITexts.SETTINGS_THUMBS_TOOLTIP_FG_COLOR_TOOLTIP) + self.thumbs_tooltip_fg_color_btn.setToolTip( + UITexts.SETTINGS_THUMBS_TOOLTIP_FG_COLOR_TOOLTIP) + thumbs_layout.addLayout(thumbs_tooltip_fg_color_layout) + + show_filename_layout = QHBoxLayout() + self.show_filename_check = QCheckBox( + UITexts.SETTINGS_THUMBS_SHOW_FILENAME_LABEL) + self.show_filename_check.setToolTip( + UITexts.SETTINGS_THUMBS_SHOW_FILENAME_TOOLTIP) + show_filename_layout.addWidget(self.show_filename_check) + thumbs_layout.addLayout(show_filename_layout) + + show_rating_layout = QHBoxLayout() + self.show_rating_check = QCheckBox(UITexts.SETTINGS_THUMBS_SHOW_RATING_LABEL) + self.show_rating_check.setToolTip(UITexts.SETTINGS_THUMBS_SHOW_RATING_TOOLTIP) + show_rating_layout.addWidget(self.show_rating_check) + thumbs_layout.addLayout(show_rating_layout) + + show_tags_layout = QHBoxLayout() + self.show_tags_check = QCheckBox(UITexts.SETTINGS_THUMBS_SHOW_TAGS_LABEL) + self.show_tags_check.setToolTip(UITexts.SETTINGS_THUMBS_SHOW_TAGS_TOOLTIP) + show_tags_layout.addWidget(self.show_tags_check) + thumbs_layout.addLayout(show_tags_layout) + + filename_lines_layout = QHBoxLayout() + self.filename_lines_spin = QSpinBox() + self.filename_lines_spin.setRange(1, 3) + filename_lines_label = QLabel(UITexts.SETTINGS_THUMBS_FILENAME_LINES_LABEL) + filename_lines_layout.addWidget(filename_lines_label) + filename_lines_layout.addWidget(self.filename_lines_spin) + filename_lines_label.setToolTip(UITexts.SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP) + self.filename_lines_spin.setToolTip( + UITexts.SETTINGS_THUMBS_FILENAME_LINES_TOOLTIP) + thumbs_layout.addLayout(filename_lines_layout) + + tags_lines_layout = QHBoxLayout() + self.tags_lines_spin = QSpinBox() + self.tags_lines_spin.setRange(1, 4) + tags_lines_label = QLabel(UITexts.SETTINGS_THUMBS_TAGS_LINES_LABEL) + tags_lines_layout.addWidget(tags_lines_label) + tags_lines_layout.addWidget(self.tags_lines_spin) + tags_lines_label.setToolTip(UITexts.SETTINGS_THUMBS_TAGS_LINES_TOOLTIP) + self.tags_lines_spin.setToolTip(UITexts.SETTINGS_THUMBS_TAGS_LINES_TOOLTIP) + thumbs_layout.addLayout(tags_lines_layout) + thumbs_layout.addStretch() + + # --- Scanner Tab --- + scan_max_level_layout = QHBoxLayout() + scan_max_level_label = QLabel(UITexts.SETTINGS_SCAN_MAX_LEVEL_LABEL) + self.scan_max_level_spin = QSpinBox() + self.scan_max_level_spin.setRange(self.scan_max_level_min, + self.scan_max_level_max) + scan_max_level_layout.addWidget(scan_max_level_label) + scan_max_level_layout.addWidget(self.scan_max_level_spin) + scan_max_level_label.setToolTip(UITexts.SETTINGS_SCAN_MAX_LEVEL_TOOLTIP) + self.scan_max_level_spin.setToolTip(UITexts.SETTINGS_SCAN_MAX_LEVEL_TOOLTIP) + scanner_layout.addLayout(scan_max_level_layout) + + # --- Search Engine --- + search_engine_layout = QHBoxLayout() + search_engine_label = QLabel(UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_LABEL) + self.search_engine_combo = QComboBox() + self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_NATIVE, "Native") + if SEARCH_CMD: + self.search_engine_combo.addItem(UITexts.SEARCH_ENGINE_BALOO, "baloosearch") + + search_engine_layout.addWidget(search_engine_label) + search_engine_layout.addWidget(self.search_engine_combo) + search_engine_label.setToolTip(UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP) + self.search_engine_combo.setToolTip( + UITexts.SETTINGS_SCANNER_SEARCH_ENGINE_TOOLTIP) + scanner_layout.addLayout(search_engine_layout) + + scan_batch_size_layout = QHBoxLayout() + scan_batch_size_label = QLabel(UITexts.SETTINGS_SCAN_BATCH_SIZE_LABEL) + self.scan_batch_size_spin = QSpinBox() + self.scan_batch_size_spin.setRange(16, 128) + scan_batch_size_layout.addWidget(scan_batch_size_label) + scan_batch_size_layout.addWidget(self.scan_batch_size_spin) + scan_batch_size_label.setToolTip(UITexts.SETTINGS_SCAN_BATCH_SIZE_TOOLTIP) + self.scan_batch_size_spin.setToolTip(UITexts.SETTINGS_SCAN_BATCH_SIZE_TOOLTIP) + scanner_layout.addLayout(scan_batch_size_layout) + + scan_full_on_start_layout = QHBoxLayout() + scan_full_on_start_label = QLabel(UITexts.SETTINGS_SCAN_FULL_ON_START_LABEL) + self.scan_full_on_start_checkbox = QCheckBox() + self.scan_full_on_start_checkbox.setText("") + + scan_full_on_start_layout.addWidget(scan_full_on_start_label) + scan_full_on_start_layout.addStretch() + scan_full_on_start_layout.addWidget(self.scan_full_on_start_checkbox) + scan_full_on_start_label.setToolTip( + UITexts.SETTINGS_SCAN_FULL_ON_START_TOOLTIP) + self.scan_full_on_start_checkbox.setToolTip( + UITexts.SETTINGS_SCAN_FULL_ON_START_TOOLTIP) + + # Threads + threads_layout = QHBoxLayout() + threads_label = QLabel(UITexts.SETTINGS_SCAN_THREADS_LABEL) + self.threads_spin = QSpinBox() + self.threads_spin.setRange(1, 32) + threads_layout.addWidget(threads_label) + threads_layout.addWidget(self.threads_spin) + threads_label.setToolTip(UITexts.SETTINGS_SCAN_THREADS_TOOLTIP) + self.threads_spin.setToolTip(UITexts.SETTINGS_SCAN_THREADS_TOOLTIP) + scanner_layout.addLayout(threads_layout) + + scanner_layout.addLayout(scan_full_on_start_layout) + scanner_layout.addStretch() + + # --- Faces & People Tab --- + faces_tab = QWidget() + faces_layout = QVBoxLayout(faces_tab) + + # Faces Header + faces_header = QLabel("Faces") + faces_header.setFont(QFont("Sans", 10, QFont.Bold)) + faces_layout.addWidget(faces_header) + + # --- Person Tags --- + person_tags_layout = QHBoxLayout() + person_tags_label = QLabel(UITexts.SETTINGS_PERSON_TAGS_LABEL) + self.person_tags_edit = QLineEdit() + self.person_tags_edit.setPlaceholderText("tag1, tag2, tag3/subtag") + self.person_tags_edit.setClearButtonEnabled(True) + person_tags_layout.addWidget(person_tags_label) + person_tags_layout.addWidget(self.person_tags_edit) + person_tags_label.setToolTip(UITexts.SETTINGS_PERSON_TAGS_TOOLTIP) + self.person_tags_edit.setToolTip(UITexts.SETTINGS_PERSON_TAGS_TOOLTIP) + faces_layout.addLayout(person_tags_layout) + + if AVAILABLE_FACE_ENGINES: + face_engine_layout = QHBoxLayout() + face_engine_label = QLabel(UITexts.SETTINGS_FACE_ENGINE_LABEL) + self.face_engine_combo = QComboBox() + self.face_engine_combo.addItems(AVAILABLE_FACE_ENGINES) + + self.download_model_btn = QPushButton( + UITexts.SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL) + self.download_model_btn.setIcon(QIcon.fromTheme("download")) + self.download_model_btn.setToolTip( + UITexts.SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL_TOOLTIP) + self.download_model_btn.clicked.connect(self.start_model_download) + + face_engine_layout.addWidget(face_engine_label) + face_engine_layout.addWidget(self.face_engine_combo, 1) + face_engine_layout.addWidget(self.download_model_btn) + + face_engine_label.setToolTip(UITexts.SETTINGS_FACE_ENGINE_TOOLTIP) + self.face_engine_combo.setToolTip(UITexts.SETTINGS_FACE_ENGINE_TOOLTIP) + faces_layout.addLayout(face_engine_layout) + else: + self.face_engine_combo = None + self.download_model_btn = None + + face_color_layout = QHBoxLayout() + face_color_label = QLabel(UITexts.SETTINGS_FACE_COLOR_LABEL) + self.face_color_btn = QPushButton() + self.face_color_btn.clicked.connect(self.choose_face_color) + face_color_layout.addWidget(face_color_label) + face_color_layout.addWidget(self.face_color_btn) + face_color_label.setToolTip(UITexts.SETTINGS_FACE_COLOR_TOOLTIP) + self.face_color_btn.setToolTip(UITexts.SETTINGS_FACE_COLOR_TOOLTIP) + faces_layout.addLayout(face_color_layout) + + face_history_layout = QHBoxLayout() + self.face_history_spin = QSpinBox() + self.face_history_spin.setRange(5, 100) + face_hist_label = QLabel(UITexts.SETTINGS_FACE_HISTORY_COUNT_LABEL) + face_history_layout.addWidget(face_hist_label) + face_history_layout.addWidget(self.face_history_spin) + face_hist_label.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP) + self.face_history_spin.setToolTip(UITexts.SETTINGS_FACE_HISTORY_TOOLTIP) + faces_layout.addLayout(face_history_layout) + + # --- Pets Section --- + faces_layout.addSpacing(10) + pets_header = QLabel("Pets") + pets_header.setFont(QFont("Sans", 10, QFont.Bold)) + faces_layout.addWidget(pets_header) + + pet_tags_layout = QHBoxLayout() + pet_tags_label = QLabel(UITexts.SETTINGS_PET_TAGS_LABEL) + self.pet_tags_edit = QLineEdit() + self.pet_tags_edit.setPlaceholderText("tag1, tag2, tag3/subtag") + self.pet_tags_edit.setClearButtonEnabled(True) + pet_tags_layout.addWidget(pet_tags_label) + pet_tags_layout.addWidget(self.pet_tags_edit) + pet_tags_label.setToolTip(UITexts.SETTINGS_PET_TAGS_TOOLTIP) + self.pet_tags_edit.setToolTip(UITexts.SETTINGS_PET_TAGS_TOOLTIP) + faces_layout.addLayout(pet_tags_layout) + + pet_engine_layout = QHBoxLayout() + pet_engine_label = QLabel(UITexts.SETTINGS_PET_ENGINE_LABEL) + self.pet_engine_combo = QComboBox() + self.pet_engine_combo.addItems(AVAILABLE_PET_ENGINES) + + self.download_pet_model_btn = QPushButton( + UITexts.SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL) + self.download_pet_model_btn.setIcon(QIcon.fromTheme("download")) + self.download_pet_model_btn.setToolTip( + UITexts.SETTINGS_DOWNLOAD_MEDIAPIPE_MODEL_TOOLTIP) + self.download_pet_model_btn.clicked.connect(self.start_pet_model_download) + + pet_engine_layout.addWidget(pet_engine_label) + pet_engine_layout.addWidget(self.pet_engine_combo, 1) + pet_engine_layout.addWidget(self.download_pet_model_btn) + pet_engine_label.setToolTip(UITexts.SETTINGS_PET_ENGINE_TOOLTIP) + self.pet_engine_combo.setToolTip(UITexts.SETTINGS_PET_ENGINE_TOOLTIP) + faces_layout.addLayout(pet_engine_layout) + + pet_color_layout = QHBoxLayout() + pet_color_label = QLabel(UITexts.SETTINGS_PET_COLOR_LABEL) + self.pet_color_btn = QPushButton() + self.pet_color_btn.clicked.connect(self.choose_pet_color) + pet_color_layout.addWidget(pet_color_label) + pet_color_layout.addWidget(self.pet_color_btn) + pet_color_label.setToolTip(UITexts.SETTINGS_PET_COLOR_TOOLTIP) + self.pet_color_btn.setToolTip(UITexts.SETTINGS_PET_COLOR_TOOLTIP) + faces_layout.addLayout(pet_color_layout) + + pet_history_layout = QHBoxLayout() + self.pet_history_spin = QSpinBox() + self.pet_history_spin.setRange(5, 100) + pet_hist_label = QLabel(UITexts.SETTINGS_PET_HISTORY_COUNT_LABEL) + pet_history_layout.addWidget(pet_hist_label) + pet_history_layout.addWidget(self.pet_history_spin) + pet_hist_label.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP) + self.pet_history_spin.setToolTip(UITexts.SETTINGS_PET_HISTORY_TOOLTIP) + faces_layout.addLayout(pet_history_layout) + + # --- Object Section --- + faces_layout.addSpacing(10) + object_header = QLabel("Object") + object_header.setFont(QFont("Sans", 10, QFont.Bold)) + faces_layout.addWidget(object_header) + + object_tags_layout = QHBoxLayout() + object_tags_label = QLabel(UITexts.SETTINGS_OBJECT_TAGS_LABEL) + self.object_tags_edit = QLineEdit() + self.object_tags_edit.setPlaceholderText("tag1, tag2, tag3/subtag") + self.object_tags_edit.setClearButtonEnabled(True) + object_tags_layout.addWidget(object_tags_label) + object_tags_layout.addWidget(self.object_tags_edit) + object_tags_label.setToolTip(UITexts.SETTINGS_OBJECT_TAGS_TOOLTIP) + self.object_tags_edit.setToolTip(UITexts.SETTINGS_OBJECT_TAGS_TOOLTIP) + faces_layout.addLayout(object_tags_layout) + + # object_engine_layout = QHBoxLayout() + # object_engine_label = QLabel(UITexts.SETTINGS_OBJECT_ENGINE_LABEL) + # self.object_engine_combo = QComboBox() + # object_engine_layout.addWidget(object_engine_label) + # object_engine_layout.addWidget(self.object_engine_combo, 1) + # object_engine_label.setToolTip(UITexts.SETTINGS_OBJECT_ENGINE_TOOLTIP) + # self.object_engine_combo.setToolTip(UITexts.SETTINGS_OBJECT_ENGINE_TOOLTIP) + # faces_layout.addLayout(object_engine_layout) + + object_color_layout = QHBoxLayout() + object_color_label = QLabel(UITexts.SETTINGS_OBJECT_COLOR_LABEL) + self.object_color_btn = QPushButton() + self.object_color_btn.clicked.connect(self.choose_object_color) + object_color_layout.addWidget(object_color_label) + object_color_layout.addWidget(self.object_color_btn) + object_color_label.setToolTip(UITexts.SETTINGS_OBJECT_COLOR_TOOLTIP) + self.object_color_btn.setToolTip(UITexts.SETTINGS_OBJECT_COLOR_TOOLTIP) + faces_layout.addLayout(object_color_layout) + + object_history_layout = QHBoxLayout() + self.object_history_spin = QSpinBox() + self.object_history_spin.setRange(5, 100) + object_hist_label = QLabel(UITexts.SETTINGS_OBJECT_HISTORY_COUNT_LABEL) + object_history_layout.addWidget(object_hist_label) + object_history_layout.addWidget(self.object_history_spin) + object_hist_label.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP) + self.object_history_spin.setToolTip(UITexts.SETTINGS_OBJECT_HISTORY_TOOLTIP) + faces_layout.addLayout(object_history_layout) + + # --- Landmark Section --- + faces_layout.addSpacing(10) + landmark_header = QLabel("Landmark") + landmark_header.setFont(QFont("Sans", 10, QFont.Bold)) + faces_layout.addWidget(landmark_header) + + landmark_tags_layout = QHBoxLayout() + landmark_tags_label = QLabel(UITexts.SETTINGS_LANDMARK_TAGS_LABEL) + self.landmark_tags_edit = QLineEdit() + self.landmark_tags_edit.setPlaceholderText("tag1, tag2, tag3/subtag") + self.landmark_tags_edit.setClearButtonEnabled(True) + landmark_tags_layout.addWidget(landmark_tags_label) + landmark_tags_layout.addWidget(self.landmark_tags_edit) + landmark_tags_label.setToolTip(UITexts.SETTINGS_LANDMARK_TAGS_TOOLTIP) + self.landmark_tags_edit.setToolTip(UITexts.SETTINGS_LANDMARK_TAGS_TOOLTIP) + faces_layout.addLayout(landmark_tags_layout) + + # landmark_engine_layout = QHBoxLayout() + # landmark_engine_label = QLabel(UITexts.SETTINGS_LANDMARK_ENGINE_LABEL) + # self.landmark_engine_combo = QComboBox() + # landmark_engine_layout.addWidget(landmark_engine_label) + # landmark_engine_layout.addWidget(self.landmark_engine_combo, 1) + # landmark_engine_label.setToolTip(UITexts.SETTINGS_LANDMARK_ENGINE_TOOLTIP) + # self.landmark_engine_combo.setToolTip(UITexts.SETTINGS_LANDMARK_ENGINE_TOOLTIP) + # faces_layout.addLayout(landmark_engine_layout) + + landmark_color_layout = QHBoxLayout() + landmark_color_label = QLabel(UITexts.SETTINGS_LANDMARK_COLOR_LABEL) + self.landmark_color_btn = QPushButton() + self.landmark_color_btn.clicked.connect(self.choose_landmark_color) + landmark_color_layout.addWidget(landmark_color_label) + landmark_color_layout.addWidget(self.landmark_color_btn) + landmark_color_label.setToolTip(UITexts.SETTINGS_LANDMARK_COLOR_TOOLTIP) + self.landmark_color_btn.setToolTip(UITexts.SETTINGS_LANDMARK_COLOR_TOOLTIP) + faces_layout.addLayout(landmark_color_layout) + + landmark_history_layout = QHBoxLayout() + self.landmark_history_spin = QSpinBox() + self.landmark_history_spin.setRange(5, 100) + landmark_hist_label = QLabel(UITexts.SETTINGS_LANDMARK_HISTORY_COUNT_LABEL) + landmark_history_layout.addWidget(landmark_hist_label) + landmark_history_layout.addWidget(self.landmark_history_spin) + landmark_hist_label.setToolTip(UITexts.SETTINGS_LANDMARK_HISTORY_TOOLTIP) + self.landmark_history_spin.setToolTip(UITexts.SETTINGS_LANDMARK_HISTORY_TOOLTIP) + faces_layout.addLayout(landmark_history_layout) + faces_layout.addStretch() + + # --- Viewer Tab --- + viewer_wheel_layout = QHBoxLayout() + viewer_wheel_label = QLabel(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_LABEL) + self.viewer_wheel_spin = QSpinBox() + self.viewer_wheel_spin.setRange(1, 10) + viewer_wheel_layout.addWidget(viewer_wheel_label) + viewer_wheel_layout.addWidget(self.viewer_wheel_spin) + viewer_wheel_label.setToolTip(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_TOOLTIP) + self.viewer_wheel_spin.setToolTip(UITexts.SETTINGS_VIEWER_WHEEL_SPEED_TOOLTIP) + viewer_layout.addLayout(viewer_wheel_layout) + + viewer_auto_resize_layout = QHBoxLayout() + self.viewer_auto_resize_check = QCheckBox( + UITexts.SETTINGS_VIEWER_AUTO_RESIZE_LABEL) + self.viewer_auto_resize_check.setToolTip( + UITexts.SETTINGS_VIEWER_AUTO_RESIZE_TOOLTIP) + viewer_auto_resize_layout.addWidget(self.viewer_auto_resize_check) + viewer_layout.addLayout(viewer_auto_resize_layout) + + filmstrip_pos_layout = QHBoxLayout() + filmstrip_pos_label = QLabel(UITexts.MENU_FILMSTRIP_POSITION) + self.filmstrip_pos_combo = QComboBox() + self.filmstrip_pos_combo.addItems([ + UITexts.FILMSTRIP_BOTTOM, + UITexts.FILMSTRIP_LEFT, + UITexts.FILMSTRIP_TOP, + UITexts.FILMSTRIP_RIGHT + ]) + filmstrip_pos_layout.addWidget(filmstrip_pos_label) + filmstrip_pos_layout.addWidget(self.filmstrip_pos_combo) + filmstrip_pos_label.setToolTip(UITexts.FILMSTRIP_POS_CHANGED_INFO) + self.filmstrip_pos_combo.setToolTip(UITexts.FILMSTRIP_POS_CHANGED_INFO) + viewer_layout.addLayout(filmstrip_pos_layout) + viewer_layout.addStretch() + + # Add tabs in the new order + tabs.addTab(thumbs_tab, UITexts.SETTINGS_GROUP_THUMBNAILS) + tabs.addTab(viewer_tab, UITexts.SETTINGS_GROUP_VIEWER) + tabs.addTab(faces_tab, UITexts.SETTINGS_GROUP_FACES) + tabs.addTab(scanner_tab, UITexts.SETTINGS_GROUP_SCANNER) + + # --- Button Box --- + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Load current settings + self.load_settings() + + def load_settings(self): + """Loads settings from the application configuration.""" + # Example: + # theme = APP_CONFIG.get("theme", "System") + # self.theme_combo.setCurrentText(theme) + + scan_max_level = APP_CONFIG.get( + "scan_max_level", SCANNER_SETTINGS_DEFAULTS["scan_max_level"]) + scan_batch_size = APP_CONFIG.get( + "scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"]) + scan_full_on_start = APP_CONFIG.get( + "scan_full_on_start", SCANNER_SETTINGS_DEFAULTS["scan_full_on_start"]) + scan_threads = APP_CONFIG.get( + "generation_threads", + SCANNER_SETTINGS_DEFAULTS.get("generation_threads", 4)) + search_engine = APP_CONFIG.get( + "search_engine", SCANNER_SETTINGS_DEFAULTS.get("search_engine", "Native")) + person_tags = APP_CONFIG.get( + "person_tags", SCANNER_SETTINGS_DEFAULTS["person_tags"]) + pet_tags = APP_CONFIG.get("pet_tags", "") + object_tags = APP_CONFIG.get("object_tags", "") + landmark_tags = APP_CONFIG.get("landmark_tags", "") + + face_detection_engine = APP_CONFIG.get("face_detection_engine") + pet_detection_engine = APP_CONFIG.get("pet_detection_engine") + object_detection_engine = APP_CONFIG.get("object_detection_engine") + landmark_detection_engine = APP_CONFIG.get("landmark_detection_engine") + + face_color = APP_CONFIG.get("face_box_color", DEFAULT_FACE_BOX_COLOR) + pet_color = APP_CONFIG.get("pet_box_color", DEFAULT_PET_BOX_COLOR) + object_color = APP_CONFIG.get("object_box_color", DEFAULT_OBJECT_BOX_COLOR) + landmark_color = APP_CONFIG.get("landmark_box_color", + DEFAULT_LANDMARK_BOX_COLOR) + + mru_tags_count = APP_CONFIG.get( + "tags_menu_max_items", TAGS_MENU_MAX_ITEMS_DEFAULT) + face_history_count = APP_CONFIG.get( + "faces_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) + pet_history_count = APP_CONFIG.get( + "pets_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) + object_history_count = APP_CONFIG.get( + "object_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) + landmark_history_count = APP_CONFIG.get( + "landmark_menu_max_items", FACES_MENU_MAX_ITEMS_DEFAULT) + + thumbs_refresh_interval = APP_CONFIG.get( + "thumbnails_refresh_interval", THUMBNAILS_REFRESH_INTERVAL_DEFAULT) + thumbs_bg_color = APP_CONFIG.get( + "thumbnails_bg_color", THUMBNAILS_BG_COLOR_DEFAULT) + thumbs_filename_color = APP_CONFIG.get( + "thumbnails_filename_color", THUMBNAILS_FILENAME_COLOR_DEFAULT) + thumbs_tags_color = APP_CONFIG.get( + "thumbnails_tags_color", THUMBNAILS_TAGS_COLOR_DEFAULT) + thumbs_rating_color = APP_CONFIG.get("thumbnails_rating_color", + THUMBNAILS_RATING_COLOR_DEFAULT) + thumbs_tooltip_bg_color = APP_CONFIG.get("thumbnails_tooltip_bg_color", + THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT) + thumbs_tooltip_fg_color = APP_CONFIG.get("thumbnails_tooltip_fg_color", + THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT) + thumbs_filename_font_size = \ + APP_CONFIG.get("thumbnails_filename_font_size", + THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT) + thumbs_tags_font_size = APP_CONFIG.get("thumbnails_tags_font_size", + THUMBNAILS_TAGS_FONT_SIZE_DEFAULT) + viewer_wheel_speed = APP_CONFIG.get("viewer_wheel_speed", + VIEWER_WHEEL_SPEED_DEFAULT) + viewer_auto_resize = APP_CONFIG.get("viewer_auto_resize_window", + VIEWER_AUTO_RESIZE_WINDOW_DEFAULT) + thumbs_filename_lines = APP_CONFIG.get("thumbnails_filename_lines", + THUMBNAILS_FILENAME_LINES_DEFAULT) + thumbs_tags_lines = APP_CONFIG.get("thumbnails_tags_lines", + THUMBNAILS_TAGS_LINES_DEFAULT) + show_filename = APP_CONFIG.get("thumbnails_show_filename", True) + show_rating = APP_CONFIG.get("thumbnails_show_rating", True) + show_tags = APP_CONFIG.get("thumbnails_show_tags", True) + filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom") + + self.scan_max_level_spin.setValue(scan_max_level) + self.scan_batch_size_spin.setValue(scan_batch_size) + self.threads_spin.setValue(scan_threads) + + # Set search engine + index = self.search_engine_combo.findData(search_engine) + if index != -1: + self.search_engine_combo.setCurrentIndex(index) + + self.scan_full_on_start_checkbox.setChecked(scan_full_on_start) + + self.person_tags_edit.setText(person_tags) + self.pet_tags_edit.setText(pet_tags) + self.object_tags_edit.setText(object_tags) + self.landmark_tags_edit.setText(landmark_tags) + + self.set_button_color(face_color) + self.set_pet_button_color(pet_color) + self.set_object_button_color(object_color) + self.set_landmark_button_color(landmark_color) + + if self.face_engine_combo and face_detection_engine in AVAILABLE_FACE_ENGINES: + self.face_engine_combo.setCurrentText(face_detection_engine) + + if self.pet_engine_combo and pet_detection_engine in AVAILABLE_PET_ENGINES: + self.pet_engine_combo.setCurrentText(pet_detection_engine) + + if object_detection_engine and hasattr(self, "object_engine_combo"): + self.object_engine_combo.setCurrentText(object_detection_engine) + if landmark_detection_engine and hasattr(self, "landmark_engine_combo"): + self.landmark_engine_combo.setCurrentText(landmark_detection_engine) + + self.mru_tags_spin.setValue(mru_tags_count) + self.face_history_spin.setValue(face_history_count) + self.pet_history_spin.setValue(pet_history_count) + self.object_history_spin.setValue(object_history_count) + self.landmark_history_spin.setValue(landmark_history_count) + + self.thumbs_refresh_spin.setValue(thumbs_refresh_interval) + self.set_thumbs_bg_button_color(thumbs_bg_color) + self.set_thumbs_filename_button_color(thumbs_filename_color) + self.set_thumbs_tags_button_color(thumbs_tags_color) + self.set_thumbs_rating_button_color(thumbs_rating_color) + self.set_thumbs_tooltip_bg_button_color(thumbs_tooltip_bg_color) + self.set_thumbs_tooltip_fg_button_color(thumbs_tooltip_fg_color) + self.filename_font_size_spin.setValue(thumbs_filename_font_size) + self.tags_font_size_spin.setValue(thumbs_tags_font_size) + self.viewer_wheel_spin.setValue(viewer_wheel_speed) + self.viewer_auto_resize_check.setChecked(viewer_auto_resize) + self.filename_lines_spin.setValue(thumbs_filename_lines) + self.tags_lines_spin.setValue(thumbs_tags_lines) + self.show_filename_check.setChecked(show_filename) + self.show_rating_check.setChecked(show_rating) + self.show_tags_check.setChecked(show_tags) + + pos_map = { + 'bottom': UITexts.FILMSTRIP_BOTTOM, + 'left': UITexts.FILMSTRIP_LEFT, + 'top': UITexts.FILMSTRIP_TOP, + 'right': UITexts.FILMSTRIP_RIGHT + } + self.filmstrip_pos_combo.setCurrentText( + pos_map.get(filmstrip_position, UITexts.FILMSTRIP_BOTTOM)) + self.update_mediapipe_status() + + def set_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.face_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_face_color = color_str + + def choose_face_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor(QColor(self.current_face_color), self) + if color.isValid(): + self.set_button_color(color.name()) + + def set_pet_button_color(self, color_str): + """Sets the background color of the pet button and stores the value.""" + self.pet_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_pet_color = color_str + + def choose_pet_color(self): + """Opens a color picker dialog for pet box.""" + color = QColorDialog.getColor(QColor(self.current_pet_color), self) + if color.isValid(): + self.set_pet_button_color(color.name()) + + def set_object_button_color(self, color_str): + """Sets the background color of the object button.""" + self.object_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_object_color = color_str + + def choose_object_color(self): + """Opens a color picker dialog for object box.""" + color = QColorDialog.getColor(QColor(self.current_object_color), self) + if color.isValid(): + self.set_object_button_color(color.name()) + + def set_landmark_button_color(self, color_str): + """Sets the background color of the landmark button.""" + self.landmark_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_landmark_color = color_str + + def choose_landmark_color(self): + """Opens a color picker dialog for landmark box.""" + color = QColorDialog.getColor(QColor(self.current_landmark_color), self) + if color.isValid(): + self.set_landmark_button_color(color.name()) + + def set_thumbs_bg_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.thumbs_bg_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_thumbs_bg_color = color_str + + def choose_thumbs_bg_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor(QColor(self.current_thumbs_bg_color), self) + if color.isValid(): + self.set_thumbs_bg_button_color(color.name()) + + def set_thumbs_filename_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.thumbs_filename_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_thumbs_filename_color = color_str + + def choose_thumbs_filename_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor(QColor(self.current_thumbs_filename_color), self) + if color.isValid(): + self.set_thumbs_filename_button_color(color.name()) + + def set_thumbs_tags_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.thumbs_tags_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_thumbs_tags_color = color_str + + def choose_thumbs_tags_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor(QColor(self.current_thumbs_tags_color), self) + if color.isValid(): + self.set_thumbs_tags_button_color(color.name()) + + def set_thumbs_rating_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.thumbs_rating_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_thumbs_rating_color = color_str + + def choose_thumbs_rating_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor(QColor(self.current_thumbs_rating_color), self) + if color.isValid(): + self.set_thumbs_rating_button_color(color.name()) + + def set_thumbs_tooltip_fg_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.thumbs_tooltip_fg_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_thumbs_tooltip_fg_color = color_str + + def choose_thumbs_tooltip_fg_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor( + QColor(self.current_thumbs_tooltip_fg_color), self) + if color.isValid(): + self.set_thumbs_tooltip_fg_button_color(color.name()) + + def set_thumbs_tooltip_bg_button_color(self, color_str): + """Sets the background color of the button and stores the value.""" + self.thumbs_tooltip_bg_color_btn.setStyleSheet( + f"background-color: {color_str}; border: 1px solid gray;") + self.current_thumbs_tooltip_bg_color = color_str + + def choose_thumbs_tooltip_bg_color(self): + """Opens a color picker dialog.""" + color = QColorDialog.getColor( + QColor(self.current_thumbs_tooltip_bg_color), self) + if color.isValid(): + self.set_thumbs_tooltip_bg_button_color(color.name()) + + def update_mediapipe_status(self): + """Checks for MediaPipe model file and updates UI accordingly.""" + if "mediapipe" not in AVAILABLE_FACE_ENGINES or not self.face_engine_combo: + if self.download_model_btn: + self.download_model_btn.hide() + return + + model_exists = os.path.exists(MEDIAPIPE_FACE_MODEL_PATH) + mediapipe_index = self.face_engine_combo.findText("mediapipe") + + if mediapipe_index != -1: + item = self.face_engine_combo.model().item(mediapipe_index) + if item: + item.setEnabled(model_exists) + + if self.download_model_btn: + self.download_model_btn.setVisible(not model_exists) + + if self.face_engine_combo.currentText() == "mediapipe" and not model_exists: + if self.face_engine_combo.count() > 1: + for i in range(self.face_engine_combo.count()): + if self.face_engine_combo.model().item(i).isEnabled(): + self.face_engine_combo.setCurrentIndex(i) + break + + if "mediapipe" not in AVAILABLE_PET_ENGINES or not self.pet_engine_combo: + if self.download_pet_model_btn: + self.download_pet_model_btn.hide() + return + + pet_model_exists = os.path.exists(MEDIAPIPE_OBJECT_MODEL_PATH) + if self.download_pet_model_btn: + self.download_pet_model_btn.setVisible(not pet_model_exists) + + def start_model_download(self): + """Starts the background thread to download the MediaPipe model.""" + self.downloader_thread = ModelDownloader( + MEDIAPIPE_FACE_MODEL_URL, MEDIAPIPE_FACE_MODEL_PATH, self) + self.downloader_thread.download_complete.connect(self.on_download_finished) + self.downloader_thread.finished.connect(self._on_downloader_finished) + + self.progress_dialog = QProgressDialog( + UITexts.MEDIAPIPE_DOWNLOADING_TEXT, UITexts.CANCEL, 0, 0, self) + self.progress_dialog.setWindowTitle(UITexts.MEDIAPIPE_DOWNLOADING_TITLE) + self.progress_dialog.setWindowModality(Qt.WindowModal) + self.progress_dialog.show() + + self.downloader_thread.start() + + def start_pet_model_download(self): + """Starts the background thread to download the MediaPipe Object model.""" + self.downloader_thread = ModelDownloader( + MEDIAPIPE_OBJECT_MODEL_URL, MEDIAPIPE_OBJECT_MODEL_PATH, self) + self.downloader_thread.download_complete.connect(self.on_download_finished) + self.downloader_thread.finished.connect(self._on_downloader_finished) + + self.progress_dialog = QProgressDialog( + UITexts.MEDIAPIPE_DOWNLOADING_TEXT, UITexts.CANCEL, 0, 0, self) + self.progress_dialog.setWindowTitle(UITexts.MEDIAPIPE_DOWNLOADING_TITLE) + self.progress_dialog.setWindowModality(Qt.WindowModal) + self.progress_dialog.show() + + self.downloader_thread.start() + + def accept(self): + """Saves settings and closes the dialog.""" + APP_CONFIG["scan_max_level"] = self.scan_max_level_spin.value() + APP_CONFIG["generation_threads"] = self.threads_spin.value() + APP_CONFIG["scan_batch_size"] = self.scan_batch_size_spin.value() + APP_CONFIG["search_engine"] = self.search_engine_combo.currentData() + APP_CONFIG["scan_full_on_start"] = self.scan_full_on_start_checkbox.isChecked() + APP_CONFIG["person_tags"] = self.person_tags_edit.text() + APP_CONFIG["pet_tags"] = self.pet_tags_edit.text() + APP_CONFIG["object_tags"] = self.object_tags_edit.text() + APP_CONFIG["landmark_tags"] = self.landmark_tags_edit.text() + APP_CONFIG["face_box_color"] = self.current_face_color + APP_CONFIG["pet_box_color"] = self.current_pet_color + APP_CONFIG["object_box_color"] = self.current_object_color + APP_CONFIG["landmark_box_color"] = self.current_landmark_color + APP_CONFIG["tags_menu_max_items"] = self.mru_tags_spin.value() + APP_CONFIG["faces_menu_max_items"] = self.face_history_spin.value() + APP_CONFIG["pets_menu_max_items"] = self.pet_history_spin.value() + APP_CONFIG["object_menu_max_items"] = self.object_history_spin.value() + APP_CONFIG["landmark_menu_max_items"] = self.landmark_history_spin.value() + + APP_CONFIG["thumbnails_refresh_interval"] = self.thumbs_refresh_spin.value() + APP_CONFIG["thumbnails_bg_color"] = self.current_thumbs_bg_color + APP_CONFIG["thumbnails_filename_color"] = self.current_thumbs_filename_color + APP_CONFIG["thumbnails_tags_color"] = self.current_thumbs_tags_color + APP_CONFIG["thumbnails_rating_color"] = self.current_thumbs_rating_color + APP_CONFIG["thumbnails_tooltip_bg_color"] = \ + self.current_thumbs_tooltip_bg_color + APP_CONFIG["thumbnails_tooltip_fg_color"] = \ + self.current_thumbs_tooltip_fg_color + APP_CONFIG["thumbnails_filename_font_size"] = \ + self.filename_font_size_spin.value() + APP_CONFIG["thumbnails_tags_font_size"] = self.tags_font_size_spin.value() + + APP_CONFIG["thumbnails_filename_lines"] = self.filename_lines_spin.value() + APP_CONFIG["thumbnails_tags_lines"] = self.tags_lines_spin.value() + APP_CONFIG["thumbnails_show_filename"] = self.show_filename_check.isChecked() + APP_CONFIG["thumbnails_show_rating"] = self.show_rating_check.isChecked() + APP_CONFIG["thumbnails_show_tags"] = self.show_tags_check.isChecked() + APP_CONFIG["viewer_wheel_speed"] = self.viewer_wheel_spin.value() + APP_CONFIG["viewer_auto_resize_window"] = \ + self.viewer_auto_resize_check.isChecked() + if self.face_engine_combo: + APP_CONFIG["face_detection_engine"] = self.face_engine_combo.currentText() + APP_CONFIG["pet_detection_engine"] = self.pet_engine_combo.currentText() + if hasattr(self, "object_engine_combo"): + APP_CONFIG["object_detection_engine"] = \ + self.object_engine_combo.currentText() + if hasattr(self, "landmark_engine_combo"): + APP_CONFIG["landmark_detection_engine"] = \ + self.landmark_engine_combo.currentText() + + pos_map_inv = { + UITexts.FILMSTRIP_BOTTOM: 'bottom', + UITexts.FILMSTRIP_LEFT: 'left', + UITexts.FILMSTRIP_TOP: 'top', + UITexts.FILMSTRIP_RIGHT: 'right' + } + selected_text = self.filmstrip_pos_combo.currentText() + APP_CONFIG["filmstrip_position"] = pos_map_inv.get(selected_text, 'bottom') + + save_app_config() + super().accept() + + def on_download_finished(self, success, message): + """Handles the result of the model download thread.""" + self.progress_dialog.close() + + if success: + QMessageBox.information( + self, UITexts.MEDIAPIPE_DOWNLOAD_SUCCESS_TITLE, + UITexts.MEDIAPIPE_DOWNLOAD_SUCCESS_TEXT) + else: + QMessageBox.critical(self, UITexts.MEDIAPIPE_DOWNLOAD_ERROR_TITLE, + UITexts.MEDIAPIPE_DOWNLOAD_ERROR_TEXT.format(message)) + self.update_mediapipe_status() + + def _on_downloader_finished(self): + self.downloader_thread = None diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..9a56bc3 --- /dev/null +++ b/setup.py @@ -0,0 +1,88 @@ +from setuptools import setup, find_packages + +setup( + name="bagheeraview", + version="0.9.11", + author="Ignacio Serantes", + description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", + long_description="A fast image viewer built with PySide6, featuring search and " + "metadata management.", + + packages=find_packages(), + + install_requires=[ + "PySide6", + "lmdb", + "exiv2", + "mediapipe", + "face_recognition", + "face_recognition_models", + "setuptools==80.0.0", + ], + + entry_points={ + 'console_scripts': [ + 'bagheeraview=bagheeraview:main' + ] + }, + + py_modules=[ + "bagheeraview", + "constants", + "settings", + "imagescanner", + "imageviewer", + "imagecontroller", + "metadatamanager", + "propertiesdialog", + "thumbnailwidget", + "widgets", + "xmpmanager", + "utils" + ], + + # extras_require={ + # 'faces': ["exiv2", "face-recognition", "face_recognition_models", "mediapipe"], + # }, + + # Classifiers to standardize the project + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Topic :: Multimedia :: Graphics :: Viewers", + "Intended Audience :: End Users/Desktop", + ], + + python_requires='>=3.8', + zip_safe=False, +) + +# from setuptools import setup +# +# +# setup( +# name="bagheeraview", +# version="0.1.9", +# author="Ignacio Serantes", +# description="Bagheera Image Viewer", +# py_modules=[ +# "bagheeraview", +# "constants", +# "imagescanner", +# "imagescanner2", +# "imageviewer", +# "imagecontroller", +# "propertiesdialog", +# "thumbnailwidget", +# "widgets" +# ], +# install_requires=[ +# "PySide6", +# "lmdb", +# ], +# entry_points={ +# 'console_scripts': ['bagheeraview=bagheeraview:main'] +# }, +# zip_safe=False, +# ) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..c20bc5c --- /dev/null +++ b/utils.py @@ -0,0 +1,45 @@ +""" +Utility Module for Bagheera. + +This module contains general-purpose utility functions and context managers +used throughout the application, such as file system helpers. +""" +import os +from contextlib import contextmanager + + +@contextmanager +def preserve_mtime(path_or_fd): + """ + Context manager to preserve the modification time (mtime) of a file. + + This is useful when performing operations that might inadvertently update + the file's modification time (like modifying extended attributes), but + where the original timestamp should be retained. Supports both file paths + and file descriptors. + + Args: + path_or_fd (str | int): The file path or file descriptor. + + Yields: + None: Control is yielded back to the caller context. + """ + mtime = None + try: + # Check for valid input (non-empty string or integer) + if path_or_fd is not None and (not isinstance(path_or_fd, str) or path_or_fd): + stat_result = os.stat(path_or_fd) + mtime = stat_result.st_mtime + except (OSError, ValueError, TypeError): + pass + + yield + + if mtime is not None: + try: + # Re-stat to get current atime, as reading might have updated it + stat_result = os.stat(path_or_fd) + atime = stat_result.st_atime + os.utime(path_or_fd, (atime, mtime)) + except (OSError, ValueError, TypeError): + pass diff --git a/widgets.py b/widgets.py new file mode 100644 index 0000000..a80a8fe --- /dev/null +++ b/widgets.py @@ -0,0 +1,1402 @@ +""" +Custom Widgets for the Bagheera Image Viewer. + +This module provides specialized Qt widgets used throughout the Bagheera UI, +including: +- TagTreeView: A tree view with custom click handling for tag management. +- TagEditWidget: A comprehensive widget for viewing and editing file tags, + integrating with Baloo for available tags. +- LayoutsWidget: A widget to manage, save, and load window layouts. +- HistoryWidget: A widget to display and manage search/view history. +""" +import os +import glob +import shutil +import lmdb +from datetime import datetime +from collections import deque + +from PySide6.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, + QMessageBox, QSizePolicy, QInputDialog, QTableWidget, QTableWidgetItem, + QMenu, QHeaderView, QAbstractItemView, QTreeView, QLabel, QTextEdit, + QComboBox, QCompleter, QToolBar +) +from PySide6.QtGui import ( + QIcon, QStandardItemModel, QStandardItem, QColor, QPainter, QPen, + QPalette, QAction, +) +from PySide6.QtCore import ( + Signal, QSortFilterProxyModel, Slot, QStringListModel, Qt +) + +from metadatamanager import XattrManager +from constants import ( + LAYOUTS_DIR, RATING_XATTR_NAME, XATTR_COMMENT_NAME, XATTR_NAME, UITexts, + FACES_MENU_MAX_ITEMS_DEFAULT, APP_CONFIG +) + + +class TagTreeView(QTreeView): + """Custom TreeView supporting Ctrl+Click to force-mark changes. + + This class extends QTreeView to implement a special handling for Ctrl+Click + events on checkable items, allowing users to forcefully toggle their state. + """ + + search_requested = Signal(object) + add_and_requested = Signal(object) + add_or_requested = Signal(object) + + def mousePressEvent(self, event): + """Handles mouse press events to implement Ctrl+Click toggling. + + If Ctrl is held down while clicking a checkable item, its check state + is toggled directly, bypassing the default model behavior. This is used + to "force" a change state on a tag. + + Args: + event (QMouseEvent): The mouse press event. + """ + index = self.indexAt(event.position().toPoint()) + if index.isValid() and event.modifiers() == Qt.ControlModifier: + # When Ctrl is pressed, we manually toggle the check state + # of the item. This allows forcing a "changed" state even if + # the tag is already applied to all/no files. + model = self.model() + source_index = (model.mapToSource(index) + if isinstance(model, QSortFilterProxyModel) + else index) + item = model.sourceModel().itemFromIndex(source_index) + if item and item.isCheckable(): + # Toggle check state manually + new_state = (Qt.Unchecked if item.checkState() == Qt.Checked + else Qt.Checked) + item.setCheckState(new_state) + return + super().mousePressEvent(event) + + def contextMenuEvent(self, event): + """Shows a context menu to trigger a search for the selected tag.""" + index = self.indexAt(event.pos()) + if index.isValid(): + model = self.model() + source_index = (model.mapToSource(index) + if isinstance(model, QSortFilterProxyModel) + else index) + item = model.sourceModel().itemFromIndex(source_index) + # Don't show menu for the root items "USED TAGS", "ALL TAGS" + if item and item.parent(): + menu = QMenu(self) + search_action = menu.addAction(QIcon.fromTheme("system-search"), + UITexts.SEARCH_BY_TAG) + add_and_action = menu.addAction(UITexts.SEARCH_ADD_AND) + add_or_action = menu.addAction(UITexts.SEARCH_ADD_OR) + + action = menu.exec(event.globalPos()) + if action == search_action: + self.search_requested.emit(index) + elif action == add_and_action: + self.add_and_requested.emit(index) + elif action == add_or_action: + self.add_or_requested.emit(index) + super().contextMenuEvent(event) + + +class TagEditWidget(QWidget): + """A widget for editing tags associated with one or more files.""" + tags_updated = Signal(dict) + + def __init__(self, main_win=None, parent=None): + """Initializes the tag editing widget and its UI components.""" + super().__init__(parent) + self.main_win = main_win + self.file_paths = [] # Paths of the files being edited + self.initial_states = {} + self.original_tags_per_file = {} + self.manually_changed = set() + self.forced_sync_tags = set() + self.item_mapping = {} + self.available_tags = [] + self._is_updating = False + self._load_all = True + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Search bar and add button + search_layout = QHBoxLayout() + self.search_bar = QLineEdit() + self.search_bar.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER) + self.search_bar.setClearButtonEnabled(True) + self.btn_add_tag = QPushButton("+") + self.btn_add_tag.setFixedWidth(30) + search_layout.addWidget(self.search_bar) + search_layout.addWidget(self.btn_add_tag) + layout.addLayout(search_layout) + + # Tag tree view setup + self.source_model = QStandardItemModel() + self.proxy_model = QSortFilterProxyModel() + self.proxy_model.setSourceModel(self.source_model) + self.proxy_model.setRecursiveFilteringEnabled(True) + + self.tree_view = TagTreeView() + self.tree_view.setModel(self.proxy_model) + self.tree_view.setHeaderHidden(True) + self.tree_view.setExpandsOnDoubleClick(False) + self.tree_view.setEditTriggers(QTreeView.NoEditTriggers) + layout.addWidget(self.tree_view) + + # Apply button + self.btn_apply = QPushButton(UITexts.TAG_APPLY_CHANGES) + layout.addWidget(self.btn_apply) + + self.load_available_tags() + self._load_all = True + + # Connect signals to slots + self.btn_apply.clicked.connect(self.save_changes) + self.btn_add_tag.clicked.connect(self.create_new_tag) + self.search_bar.textChanged.connect(self.handle_search) + self.source_model.itemChanged.connect(self.sync_tags) + self.tree_view.search_requested.connect(self.on_search_requested) + self.tree_view.add_and_requested.connect(self.on_add_and_requested) + self.tree_view.add_or_requested.connect(self.on_add_or_requested) + + def set_files_data(self, files_data): + """Sets the files whose tags are to be edited. + + Args: + files_data (dict): A dictionary mapping file paths to a list of + their current tags. + """ + self.file_paths = list(files_data.keys()) + self.original_tags_per_file = {path: set(tags) for path, + tags in files_data.items()} + self.refresh_ui() + + def load_available_tags(self): + """Loads all known tags from the Baloo index database.""" + db_path = os.path.expanduser("~/.local/share/baloo/index") + if not os.path.exists(db_path): + self.available_tags = [] + return + tags = [] + try: + # Connect to the LMDB environment for Baloo + with lmdb.Environment(db_path, subdir=False, readonly=True, + lock=False, max_dbs=20) as env: + postingdb = env.open_db(b'postingdb') + with env.begin() as txn: + cursor = txn.cursor(postingdb) + prefix = b'TAG-' + # Iterate over keys starting with the tag prefix + if cursor.set_range(prefix): + for key, _ in cursor: + if not key.startswith(prefix): + break + tags.append(key[4:].decode('utf-8')) + except Exception: + # Silently fail if Baloo DB is not accessible + pass + self.available_tags = tags + + def init_data(self): + """Initializes or updates the tag tree model based on current files.""" + self._is_updating = True + try: + if self._load_all: + # First time loading: build the full tree structure + self.source_model.clear() + self.item_mapping = {} + self.root_favs = QStandardItem(UITexts.TAG_USED_TAGS) + self.root_all = QStandardItem(UITexts.TAG_ALL_TAGS) + + self.root_favs.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.root_all.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + + # self.source_model.insertRow(self.root_favs, 0) + self.source_model.appendRow(self.root_favs) + self.source_model.appendRow(self.root_all) + + tag_counts = {} + for path in self.file_paths: + tags = self.original_tags_per_file.get(path, set()) + for t in tags: + tag_counts[t] = tag_counts.get(t, 0) + 1 + # Combine tags from files and all available tags from Baloo + master = sorted(list(set(self.available_tags) | set(tag_counts.keys()))) + total = len(self.file_paths) if self.file_paths else 1 + + for t_path in master: + count = tag_counts.get(t_path, 0) + is_checked = count > 0 + # Italicize if the tag is applied to some but not all files + is_italic = (0 < count < total and len(self.file_paths) > 1) + self.initial_states[t_path] = is_checked + + self.get_or_create_node(t_path, self.root_all, is_checked, + is_italic) + if is_checked: + self.get_or_create_node(t_path, self.root_favs, True, + is_italic) + + self._load_all = False + + else: + # Subsequent loads: update existing tree + tag_counts = {} + for path in self.file_paths: + tags = self.original_tags_per_file.get(path, set()) + for t in tags: + tag_counts[t] = tag_counts.get(t, 0) + 1 + total = len(self.file_paths) if self.file_paths else 1 + + if self.root_favs.hasChildren(): + self.root_favs.removeRows(0, self.root_favs.rowCount()) + # Clear references to deleted items in the 'Used Tags' section + for key in self.item_mapping: + self.item_mapping[key][1] = None + + # Optimization: Reset known nodes via map instead of recursive traversal + for t_path, nodes in self.item_mapping.items(): + self.initial_states[t_path] = False + node_all = nodes[0] + if node_all: + if node_all.checkState() != Qt.Unchecked: + node_all.setCheckState(Qt.Unchecked) + font = node_all.font() + if font.italic(): + font.setItalic(False) + node_all.setFont(font) + if node_all.foreground().color().name() != "#ffffff": + node_all.setForeground(QColor("#ffffff")) + + # Iterate only active tags to check/italicize + for t_path, count in tag_counts.items(): + if count > 0: + is_italic = (0 < count < total and len(self.file_paths) > 1) + self.initial_states[t_path] = True + + self.get_or_create_node(t_path, self.root_favs, True, is_italic) + self.get_or_create_node(t_path, self.root_all, True, is_italic) + + self.reset_expansion() + finally: + self._is_updating = False + + def get_or_create_node(self, full_path, root, checked, italic): + """Finds or creates a hierarchical node in the tree for a given tag path. + + Args: + full_path (str): The full hierarchical tag (e.g., "Photos/Family"). + root (QStandardItem): The root item to build under (e.g., "All Tags"). + checked (bool): The initial check state of the final node. + italic (bool): Whether the node font should be italic. + """ + parts, curr = full_path.split('/'), root + for i, part in enumerate(parts): + c_path = "/".join(parts[:i+1]) + found = None + # Find if child already exists + for row in range(curr.rowCount()): + if curr.child(row, 0).text() == part: + found = curr.child(row, 0) + break + if not found: + # Create new node if it doesn't exist + node = QStandardItem(part) + if c_path == full_path: + # This is the final node in the path, make it checkable + node.setCheckable(True) + node.setCheckState(Qt.Checked if checked else Qt.Unchecked) + self._style_node(node, full_path, checked, italic) + if full_path not in self.item_mapping: + self.item_mapping[full_path] = [None, None] + # Store reference to the node under 'all' or 'used' root + self.item_mapping[full_path][0 if root == self.root_all else 1] = \ + node + curr.appendRow(node) + curr = node + else: + # Node already exists, update it + curr = found + if c_path == full_path: + curr.setCheckState(Qt.Checked if checked else Qt.Unchecked) + self._style_node(curr, full_path, checked, italic) + + def _style_node(self, node, path, current_checked, italic): + """Applies visual styling (font, color) to a tag node.""" + font = node.font() + # Use italic for partially applied tags, unless forced + font.setItalic(italic if path not in self.forced_sync_tags else False) + node.setFont(font) + # Highlight manually changed tags + color = "#569cd6" if path in self.manually_changed else "#ffffff" + node.setForeground(QColor(color)) + + def reconstruct_path(self, item): + """Builds the full hierarchical tag path from a model item.""" + p, c = [], item + while c and c not in [self.root_all, self.root_favs]: + p.insert(0, c.text()) + c = c.parent() + return "/".join(p) if p else None + + def sync_tags(self, item): + """Synchronizes the state of a tag between the 'Used' and 'All' trees. + + Triggered when a tag's check state changes. It also tracks manual + changes to highlight them and prepare for saving. + """ + if self._is_updating: + return + if not item: + return + path = self.reconstruct_path(item) + if not path or path not in self.item_mapping: + return + + new_state = (item.checkState() == Qt.Checked) + if new_state: # and self.item_mapping[path][1] is None: + # If a tag is checked, ensure it appears in the "Used Tags" list + self.get_or_create_node(path, self.root_favs, True, item.font().italic()) + self.reset_expansion() + + if QApplication.keyboardModifiers() == Qt.ControlModifier: + # Ctrl+Click forces a tag to be considered "changed" + self.forced_sync_tags.add(path) + + # Track if the state differs from the initial state + if (new_state != self.initial_states.get(path, False) + or path in self.forced_sync_tags): + self.manually_changed.add(path) + else: + self.manually_changed.discard(path) + + # Update the corresponding node in the other tree to match + self._is_updating = True + try: + for node in self.item_mapping[path]: + if node: + try: + node.setCheckState(item.checkState()) + self._style_node(node, path, new_state, node.font().italic()) + except RuntimeError: + pass + finally: + self._is_updating = False + + def _get_tag_search_string(self, proxy_index): + """Generates the search string for the tag at the given index.""" + source_index = self.proxy_model.mapToSource(proxy_index) + item = self.source_model.itemFromIndex(source_index) + if not item: + return "" + full_path = self.reconstruct_path(item) + if not full_path: + return "" + words = full_path.replace('/', ' ').split() + search_terms = [f"tags:{word}" for word in words if word] + return " ".join(search_terms) + + def _get_current_query_text(self): + """Extracts the effective query text from the main window search input.""" + if not self.main_win: + return "" + text = self.main_win.search_input.currentText().strip() + + if text.startswith("search:/"): + return text[8:] + + if text.startswith("file:/") or text.startswith("/") or os.path.exists(text): + return "" + return text + + @Slot(object) + def on_search_requested(self, proxy_index): + """Handles the request to search for a tag from the context menu.""" + search_string = self._get_tag_search_string(proxy_index) + + if search_string: + self.main_win.process_term(f"search:/{search_string}") + + @Slot(object) + def on_add_and_requested(self, proxy_index): + """Handles request to add a tag with AND to the current search.""" + if not self.main_win: + return + new_term = self._get_tag_search_string(proxy_index) + if not new_term: + return + + current_query = self._get_current_query_text() + + if current_query: + final_query = f"({current_query}) AND ({new_term})" + else: + final_query = new_term + + self.main_win.process_term(f"search:/{final_query}") + + @Slot(object) + def on_add_or_requested(self, proxy_index): + """Handles request to add a tag with OR to the current search.""" + if not self.main_win: + return + new_term = self._get_tag_search_string(proxy_index) + if not new_term: + return + + current_query = self._get_current_query_text() + + if current_query: + final_query = f"({current_query}) OR ({new_term})" + else: + final_query = new_term + + self.main_win.process_term(f"search:/{final_query}") + + def save_changes(self): + """Applies the tracked tag changes to the selected files' xattrs.""" + QApplication.setOverrideCursor(Qt.WaitCursor) + paths_to_index = [] + all_newly_added_tags = set() + updated_files_tags = {} + try: + for path in self.file_paths: + try: + file_tags = self.original_tags_per_file.get(path, set()).copy() + + original_file_tags = set(file_tags) + + for t in self.manually_changed: + nodes = self.item_mapping.get(t) + if not nodes: + continue + node = nodes[0] or nodes[1] + if node.checkState() == Qt.Checked: + file_tags.add(t) + else: + file_tags.discard(t) + + # Filter out any empty or whitespace-only tags before saving. + # The use of a set already handles duplicates. + final_tags = {tag.strip() for tag in file_tags if tag.strip()} + + newly_added_tags = final_tags - original_file_tags + all_newly_added_tags.update(newly_added_tags) + + tags_str = ",".join(sorted(list(final_tags))) if final_tags \ + else None + XattrManager.set_attribute(path, XATTR_NAME, tags_str) + + self.original_tags_per_file[path] = final_tags + updated_files_tags[path] = sorted(list(final_tags)) + paths_to_index.append(path) + except Exception: + continue + + if self.main_win: + for tag in sorted(list(all_newly_added_tags)): + self.main_win.add_to_mru_tags(tag) + + # Refresh status bar on any open viewer showing one of the modified files + if self.main_win: + for viewer in self.main_win.viewers: + if viewer.controller.get_current_path() in paths_to_index: + viewer.update_status_bar() + + self.load_available_tags() + self._load_all = False + self.refresh_ui() + self.tags_updated.emit(updated_files_tags) + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + finally: + QApplication.restoreOverrideCursor() + + def create_new_tag(self): + """Opens a dialog to create a new tag and adds it to the trees.""" + new_tag, ok = QInputDialog.getText(self, UITexts.TAG_NEW_TAG_TITLE, + UITexts.TAG_NEW_TAG_TEXT) + if ok and new_tag.strip(): + tag_path = new_tag.strip() + # Mark it as a forced, manual change to ensure it gets saved + self.forced_sync_tags.add(tag_path) + self.manually_changed.add(tag_path) + # Add the new tag to both trees, checked by default + self.get_or_create_node(tag_path, self.root_all, True, False) + self.get_or_create_node(tag_path, self.root_favs, True, False) + self.reset_expansion() + + def handle_search(self, text): + """Filters the tag tree based on the search bar text.""" + self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.proxy_model.setFilterFixedString(text) + if text: + self.tree_view.expandAll() + else: + self.reset_expansion(True) + + def reset_expansion(self, handling_search=False): + """Resets the tree expansion to a default state.""" + if handling_search: + self.tree_view.collapseAll() + fav_idx = self.proxy_model.index(0, 0) + if fav_idx.isValid(): + self._expand_recursive(fav_idx) + all_idx = self.proxy_model.index(1, 0) + if all_idx.isValid(): + # Expand only the top level of the "All Tags" section + self.tree_view.expand(all_idx) + + def _expand_recursive(self, proxy_idx): + """Recursively expands an item and all its children.""" + self.tree_view.expand(proxy_idx) + for i in range(self.proxy_model.rowCount(proxy_idx)): + child = self.proxy_model.index(i, 0, proxy_idx) + if child.isValid(): + self._expand_recursive(child) + + def refresh_ui(self): + """Resets the widget's state and re-initializes the data.""" + self.initial_states = {} + self.manually_changed = set() + self.forced_sync_tags = set() + self.init_data() + + +class LayoutsWidget(QWidget): + """A widget for managing saved window and viewer layouts.""" + def __init__(self, main_win): + """Initializes the layouts widget and its UI. + + Args: + main_win (MainWindow): Reference to the main application window. + """ + super().__init__() + self.main_win = main_win + layout = QVBoxLayout(self) + + # Table to display saved layouts + self.table = QTableWidget() + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels(UITexts.LAYOUTS_TABLE_HEADER) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.verticalHeader().setVisible(False) + self.table.setSortingEnabled(True) + self.table.doubleClicked.connect(self.load_selected) + layout.addWidget(self.table) + + toolbar = QToolBar() + layout.addWidget(toolbar) + + load_action = QAction(QIcon.fromTheme("document-open"), UITexts.LOAD, self) + load_action.triggered.connect(self.load_selected) + toolbar.addAction(load_action) + + create_action = QAction(QIcon.fromTheme("document-new"), UITexts.CREATE, self) + create_action.triggered.connect(self.create_layout) + toolbar.addAction(create_action) + + save_action = QAction(QIcon.fromTheme("document-save"), UITexts.SAVE, self) + save_action.triggered.connect(self.save_selected_layout) + toolbar.addAction(save_action) + + rename_action = QAction(QIcon.fromTheme("edit-rename"), UITexts.RENAME, self) + rename_action.triggered.connect(self.rename_layout) + toolbar.addAction(rename_action) + + copy_action = QAction(QIcon.fromTheme("edit-copy"), UITexts.COPY, self) + copy_action.triggered.connect(self.copy_layout) + toolbar.addAction(copy_action) + + delete_action = QAction(QIcon.fromTheme("edit-delete"), UITexts.DELETE, self) + delete_action.triggered.connect(self.delete_layout) + toolbar.addAction(delete_action) + + self.refresh_list() + + def resizeEvent(self, event): + """Adjusts column widths on resize.""" + width = self.table.viewport().width() + self.table.setColumnWidth(0, int(width * 0.80)) + super().resizeEvent(event) + + def refresh_list(self): + """Reloads the list of saved layouts from the layouts directory.""" + self.table.setSortingEnabled(False) + self.table.setRowCount(0) + if not os.path.exists(LAYOUTS_DIR): + return + + # Find all .layout files + files = glob.glob(os.path.join(LAYOUTS_DIR, "*.layout")) + files.sort(key=os.path.getmtime, reverse=True) + + self.table.setRowCount(len(files)) + for i, f_path in enumerate(files): + name = os.path.basename(f_path).replace(".layout", "") + mtime = os.path.getmtime(f_path) + dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + + item_name = QTableWidgetItem(name) + item_name.setData(Qt.UserRole, f_path) + item_name.setData(Qt.UserRole, f_path) # Store full path in item + item_date = QTableWidgetItem(dt) + + self.table.setItem(i, 0, item_name) + self.table.setItem(i, 1, item_date) + self.table.setSortingEnabled(True) + + def get_selected_path(self): + """Gets the file path of the currently selected layout in the table. + + Returns: + str or None: The full path to the selected .layout file, or None. + """ + row = self.table.currentRow() + if row >= 0: + return self.table.item(row, 0).data(Qt.UserRole) + return None + + def load_selected(self): + """Loads the currently selected layout.""" + path = self.get_selected_path() + if path: + self.main_win.restore_layout(path) + + def create_layout(self): + """Saves the current session as a new layout.""" + self.main_win.save_layout() + self.refresh_list() + + def save_selected_layout(self): + """Overwrites the selected layout with the current session state.""" + path = self.get_selected_path() + if path: + self.main_win.save_layout(target_path=path) + else: + # If nothing is selected, treat it as a "create new" action + self.create_layout() + + def delete_layout(self): + """Deletes the selected layout file after confirmation.""" + path = self.get_selected_path() + if path: + if QMessageBox.question(self, + UITexts.CONFIRM_DELETE_LAYOUT_TITLE, + UITexts.CONFIRM_DELETE_LAYOUT_TEXT.format( + os.path.basename(path)), QMessageBox.Yes + | QMessageBox.No) == QMessageBox.Yes: + os.remove(path) + self.refresh_list() + + def rename_layout(self): + """Renames the selected layout file.""" + path = self.get_selected_path() + if not path: + return + old_name = os.path.basename(path).replace(".layout", "") + new_name, ok = QInputDialog.getText(self, + UITexts.RENAME_LAYOUT_TITLE, + UITexts.RENAME_LAYOUT_TEXT, + text=old_name) + if ok and new_name: + new_path = os.path.join(os.path.dirname(path), new_name + ".layout") + if not os.path.exists(new_path): + os.rename(path, new_path) + self.refresh_list() + else: + QMessageBox.warning(self, UITexts.ERROR, UITexts.LAYOUT_ALREADY_EXISTS) + + def copy_layout(self): + """Creates a copy of the selected layout with a new name.""" + path = self.get_selected_path() + if not path: + return + old_name = os.path.basename(path).replace(".layout", "") + new_name, ok = QInputDialog.getText(self, + UITexts.COPY_LAYOUT_TITLE, + UITexts.COPY_LAYOUT_TEXT, + text=old_name + "_copy") + if ok and new_name: + new_path = os.path.join(os.path.dirname(path), new_name + ".layout") + if not os.path.exists(new_path): + shutil.copy(path, new_path) + self.refresh_list() + else: + QMessageBox.warning(self, UITexts.ERROR, UITexts.LAYOUT_ALREADY_EXISTS) + + +class HistoryWidget(QWidget): + """A widget to display and manage the application's browsing history.""" + def __init__(self, main_win): + """Initializes the history widget and its UI. + + Args: + main_win (MainWindow): Reference to the main application window. + """ + super().__init__() + self.main_win = main_win + layout = QVBoxLayout(self) + + # Table to display history entries + self.table = QTableWidget() + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels(UITexts.HISTORY_TABLE_HEADER) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.verticalHeader().setVisible(False) + self.table.setSortingEnabled(True) + self.table.doubleClicked.connect(self.open_selected) + self.table.setContextMenuPolicy(Qt.CustomContextMenu) + self.table.customContextMenuRequested.connect(self.show_context_menu) + layout.addWidget(self.table) + + toolbar = QToolBar() + layout.addWidget(toolbar) + + clear_action = QAction(QIcon.fromTheme("user-trash"), + UITexts.HISTORY_BTN_CLEAR_ALL_TOOLTIP, self) + clear_action.triggered.connect(self.clear_all) + toolbar.addAction(clear_action) + + delete_action = QAction(QIcon.fromTheme("edit-delete"), + UITexts.HISTORY_BTN_DELETE_SELECTED_TOOLTIP, self) + delete_action.triggered.connect(self.delete_selected) + toolbar.addAction(delete_action) + + delete_older_action = QAction(QIcon.fromTheme("edit-clear"), + UITexts.HISTORY_BTN_DELETE_OLDER_TOOLTIP, self) + delete_older_action.triggered.connect(self.delete_older) + toolbar.addAction(delete_older_action) + + self.refresh_list() + + def resizeEvent(self, event): + """Adjusts column widths on resize.""" + width = self.table.viewport().width() + self.table.setColumnWidth(0, int(width * 0.80)) + super().resizeEvent(event) + + def refresh_list(self): + """Reloads the history from the main window's data.""" + self.table.setSortingEnabled(False) + self.table.setRowCount(0) + + # Filter invalid items to avoid crashes and empty rows + history = [e for e in self.main_win.full_history + if isinstance(e, dict) and e.get('path')] + + self.table.setRowCount(len(history)) + for i, entry in enumerate(history): + raw_path = entry.get('path', '') + text = raw_path.replace("search:/", "").replace("file:/", "") + icon_name = "system-search" + + if raw_path.startswith("file:/"): + path = raw_path[6:] + if os.path.isdir(os.path.expanduser(path)): + icon_name = "folder" + else: + icon_name = "image-x-generic" + elif raw_path.startswith("layout:/"): + icon_name = "view-grid" + text = text.replace("layout:/", "") + elif raw_path.startswith("search:/"): + icon_name = "system-search" + + item_name = QTableWidgetItem(text) + item_name.setIcon(QIcon.fromTheme(icon_name)) + item_name.setData(Qt.UserRole, raw_path) + item_date = QTableWidgetItem(entry.get('date', '')) + self.table.setItem(i, 0, item_name) + self.table.setItem(i, 1, item_date) + self.table.setSortingEnabled(True) + + def open_selected(self): + """Opens the path/search from the selected history item.""" + row = self.table.currentRow() + if row >= 0: + # Use UserRole if available (contains full raw path), else fallback to text + path = self.table.item(row, 0).data(Qt.UserRole) + if not path: + path = self.table.item(row, 0).text() + self.main_win.process_term(path) + + def show_context_menu(self, pos): + """Shows a context menu for the history table.""" + item = self.table.itemAt(pos) + if not item: + return + + menu = QMenu(self) + delete_action = menu.addAction(QIcon.fromTheme("edit-delete"), + UITexts.DELETE) + action = menu.exec(self.table.mapToGlobal(pos)) + + if action == delete_action: + self.table.setCurrentItem(item) + self.delete_selected() + + def clear_all(self): + """Clears the entire history after confirmation.""" + if QMessageBox.question(self, + UITexts.HISTORY_CLEAR_ALL_TITLE, + UITexts.HISTORY_CLEAR_ALL_TEXT, QMessageBox.Yes + | QMessageBox.No) == QMessageBox.Yes: + self.main_win.full_history = [] + self.main_win.save_full_history() + self.refresh_list() + + def delete_selected(self): + """Deletes the currently selected entry from the history.""" + row = self.table.currentRow() + if row >= 0: + item = self.table.item(row, 0) + path = item.data(Qt.UserRole) + if not path: + path = item.text() + + # Safely filter history handling potentially corrupted items + self.main_win.full_history = [ + x for x in self.main_win.full_history + if isinstance(x, dict) and x.get('path') != path] + self.main_win.save_full_history() + self.refresh_list() + + def delete_older(self): + """Deletes the selected history entry and all entries older than it.""" + row = self.table.currentRow() + if row >= 0: + # The visual row might not match the list index if sorted + item = self.table.item(row, 0) + path = item.data(Qt.UserRole) + if not path: + path = item.text() + idx = -1 + # Find the actual index in the unsorted full_history list + for i, entry in enumerate(self.main_win.full_history): + if isinstance(entry, dict) and entry.get('path') == path: + idx = i + break + + if idx != -1: + # Delete from this index to the end (older entries) + del self.main_win.full_history[idx:] + self.main_win.save_full_history() + self.refresh_list() + + +class RatingStar(QLabel): + """An individual star label for the rating widget.""" + # Emits the star index (1-5) + clicked = Signal(int) + + def __init__(self, index, parent=None): + super().__init__(parent) + self.index = index + self.setCursor(Qt.PointingHandCursor) + + def mousePressEvent(self, event): + """Handles the click to emit its own index.""" + if event.button() == Qt.LeftButton: + self.clicked.emit(self.index) + super().mousePressEvent(event) + + +class RatingWidget(QWidget): + """A widget to view and edit file ratings.""" + rating_updated = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.file_paths = [] + self._current_rating = 0 + + # Icons and colors + self.star_full = QIcon.fromTheme("rating_full") + self.star_half = QIcon.fromTheme("rating_half") + self.star_empty = QIcon.fromTheme("rating_empty") + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(2) + + rating_layout = QHBoxLayout() + rating_layout.addWidget(QLabel(UITexts.INFO_RATING_LABEL)) + self.stars = [] + for i in range(1, 6): + star_label = RatingStar(i, self) + star_label.clicked.connect(self.on_star_clicked) + self.stars.append(star_label) + rating_layout.addWidget(star_label) + rating_layout.addStretch() + + layout.addLayout(rating_layout) + + self.btn_apply = QPushButton(UITexts.TAG_APPLY_CHANGES) + self.btn_apply.clicked.connect(self.save_rating) + self.btn_apply.hide() + + btn_container_layout = QHBoxLayout() + btn_container_layout.addStretch() + btn_container_layout.addWidget(self.btn_apply) + layout.addLayout(btn_container_layout) + + self.update_stars() + + def set_files(self, file_paths): + """Sets the current files and loads rating from the first one.""" + self.file_paths = file_paths if file_paths else [] + self.load_rating() + self.btn_apply.hide() + + def load_rating(self): + """Loads the rating using the XattrManager.""" + self._current_rating = 0 + if self.file_paths: + rating_str = XattrManager.get_attribute(self.file_paths[0], + RATING_XATTR_NAME, "0") + try: + self._current_rating = int(rating_str) + except (ValueError, TypeError): + self._current_rating = 0 + self.update_stars() + + @Slot(int) + def on_star_clicked(self, star_index): + """ + Handles a click on a star to cycle its state and update the rating. + The cycle is: OFF -> FULL -> HALF -> OFF. + """ + rating_for_half = star_index * 2 - 1 + rating_for_full = star_index * 2 + rating_previous = (star_index - 1) * 2 + + current_rating = self._current_rating + + if current_rating > rating_for_full: + # If a higher star is active, clicking a lower one sets the rating + # to "full" of the clicked star. + self._current_rating = rating_for_full + elif current_rating == rating_for_full: + # The star is full: cycle to half. + self._current_rating = rating_for_half + elif current_rating == rating_for_half: + # The star is half: cycle to off. + self._current_rating = rating_previous + else: # current_rating < rating_for_half + # The star is off: cycle to full. + self._current_rating = rating_for_full + + self.update_stars() + self.btn_apply.show() + + def update_stars(self): + """Updates the appearance of the 5 stars according to the rating.""" + rating = self._current_rating + pixmap_size = self.fontMetrics().height() + 8 + + # Get base pixmaps from the theme + full_pixmap = self.star_full.pixmap(pixmap_size, pixmap_size) + half_pixmap = self.star_half.pixmap(pixmap_size, pixmap_size) + empty_pixmap = self.star_empty.pixmap(pixmap_size, pixmap_size) + + for i, star_label in enumerate(self.stars): + star_value = i * 2 + 2 + + if rating >= star_value: + star_label.setPixmap(full_pixmap) + elif rating == star_value - 1: + star_label.setPixmap(half_pixmap) + else: + star_label.setPixmap(empty_pixmap) + + def save_rating(self): + """Saves the current rating using the XattrManager.""" + if not self.file_paths: + return + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + value_to_set = str(self._current_rating) \ + if self._current_rating > 0 else None + for path in self.file_paths: + XattrManager.set_attribute(path, RATING_XATTR_NAME, value_to_set) + self.btn_apply.hide() + self.rating_updated.emit() + except IOError as e: + QMessageBox.critical(self, UITexts.ERROR, str(e)) + finally: + QApplication.restoreOverrideCursor() + + +class CircularProgressBar(QWidget): + """A circular progress bar widget.""" + def __init__(self, parent=None): + super().__init__(parent) + self._value = 0 + self._custom_color = None + # Match the height of other status bar widgets like buttons + self.setFixedSize(22, 22) + self.setToolTip(f"{self._value}%") + + def setCustomColor(self, color): + """Sets a custom color for the progress arc. Pass None to use default.""" + self._custom_color = color + self.update() + + def setValue(self, value): + """Sets the progress value (0-100).""" + if self._value != value: + self._value = max(0, min(100, value)) + self.setToolTip(f"{self._value}%") + self.update() # Trigger a repaint + + def value(self): + """Returns the current progress value.""" + return self._value + + def paintEvent(self, event): + """Paints the circular progress bar.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Use the widget's rectangle, with a small margin + rect = self.rect().adjusted(2, 2, -2, -2) + + # 1. Draw the background circle (the track) + # Use a color from the palette for theme-awareness + track_color = self.palette().color(self.backgroundRole()).darker(130) + painter.setPen(QPen(track_color, 2)) + painter.drawEllipse(rect) + + # 2. Draw the foreground arc (the progress) + if self._value > 0: + # Use the palette's highlight color for the progress arc + if self._custom_color: + progress_color = self._custom_color + else: + progress_color = self.palette().color(QPalette.Highlight) + pen = QPen(progress_color, 2) + pen.setCapStyle(Qt.RoundCap) + painter.setPen(pen) + + # Angles are in 1/16th of a degree. + # 0 degrees is at the 3 o'clock position. We start at 12 o'clock (90 deg). + start_angle = 90 * 16 + # Span is negative for clockwise. 360 degrees for 100%. + span_angle = -int(self._value * 3.6 * 16) + + painter.drawArc(rect, start_angle, span_angle) + + +class FaceNameInputWidget(QWidget): + """ + A widget for entering names that maintains a history of the last N used names. + + It features autocomplete and is sorted by recent usage (MRU). + """ + name_accepted = Signal(str) + + def __init__(self, main_win, parent=None, region_type="Face"): + """Initializes the widget with a history based on configuration.""" + super().__init__(parent) + self.main_win = main_win + self.region_type = region_type + # Usamos deque para gestionar el historial de forma eficiente con un máximo + # configurable de elementos. + max_items = APP_CONFIG.get("faces_menu_max_items", + FACES_MENU_MAX_ITEMS_DEFAULT) + if self.region_type == "Pet": + max_items = APP_CONFIG.get("pets_menu_max_items", + FACES_MENU_MAX_ITEMS_DEFAULT) + elif self.region_type == "Object": + max_items = APP_CONFIG.get("object_menu_max_items", + FACES_MENU_MAX_ITEMS_DEFAULT) + elif self.region_type == "Landmark": + max_items = APP_CONFIG.get("landmark_menu_max_items", + FACES_MENU_MAX_ITEMS_DEFAULT) + self.history = deque(maxlen=max_items) + self.name_to_tags_map = {} + + self._setup_ui() + self._connect_signals() + + def _setup_ui(self): + """Configures the user interface of the widget.""" + self.name_combo = QComboBox() + self.name_combo.setEditable(True) + self.name_combo.setInsertPolicy(QComboBox.NoInsert) + self.name_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.name_combo.setToolTip(UITexts.FACE_NAME_TOOLTIP) + self.name_combo.lineEdit().setClearButtonEnabled(True) + + # 2. Completer para la funcionalidad de autocompletado. + self.completer = QCompleter(self) + self.completer.setCaseSensitivity(Qt.CaseInsensitive) + self.completer.setFilterMode(Qt.MatchContains) + self.completer.setCompletionMode(QCompleter.PopupCompletion) + + self.model = QStringListModel(self) + self.completer.setModel(self.model) + self.name_combo.setCompleter(self.completer) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + layout.addWidget(self.name_combo) + + def _connect_signals(self): + """Connects signals to slots.""" + self.name_combo.lineEdit().returnPressed.connect(self._on_accept) + self.name_combo.activated.connect(self._on_accept) + + def _on_accept(self): + """ + Triggered when Enter is pressed or an item is selected. + Emits the `name_accepted` signal. + """ + entered_name = self.name_combo.currentText().strip() + if not entered_name: + return + + matches = self.name_to_tags_map.get(entered_name, []) + final_tag = None + + if len(matches) == 0: + reply = QMessageBox.question( + self, UITexts.CREATE_TAG_TITLE, + UITexts.CREATE_TAG_TEXT.format(entered_name), + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + if self.region_type == "Pet": + parent_tags_str = APP_CONFIG.get("pet_tags", "Pet") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Pet" + dialog_title = UITexts.NEW_PET_TAG_TITLE + dialog_text = UITexts.NEW_PET_TAG_TEXT + elif self.region_type == "Object": + parent_tags_str = APP_CONFIG.get("object_tags", "Object") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Object" + dialog_title = UITexts.NEW_OBJECT_TAG_TITLE + dialog_text = UITexts.NEW_OBJECT_TAG_TEXT + elif self.region_type == "Landmark": + parent_tags_str = APP_CONFIG.get("landmark_tags", "Landmark") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Landmark" + dialog_title = UITexts.NEW_LANDMARK_TAG_TITLE + dialog_text = UITexts.NEW_LANDMARK_TAG_TEXT + else: + parent_tags_str = APP_CONFIG.get("person_tags", "Person") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Person" + dialog_title = UITexts.NEW_PERSON_TAG_TITLE + dialog_text = UITexts.NEW_PERSON_TAG_TEXT + + default_parent = parent_tags_str.split(',')[0].strip() + suggested_tag = f"{default_parent}/{entered_name}" + + new_full_tag, ok = QInputDialog.getText( + self, dialog_title, dialog_text, QLineEdit.Normal, suggested_tag) + if ok and new_full_tag: + final_tag = new_full_tag.strip() + elif len(matches) == 1: + final_tag = matches[0] + else: + chosen_tag, ok = QInputDialog.getItem( + self, UITexts.SELECT_TAG_TITLE, + UITexts.SELECT_TAG_TEXT.format(entered_name), + matches, 0, False) + if ok and chosen_tag: + final_tag = chosen_tag + + if final_tag: + self.update_history(final_tag) + self.name_accepted.emit(final_tag) + + def update_history(self, full_tag_path: str): + """ + Updates the history. Moves the used name to the top of the list (MRU - + Most Recently Used). + """ + if not full_tag_path: + return + + if full_tag_path in self.history: + self.history.remove(full_tag_path) + self.history.appendleft(full_tag_path) + + def _update_models(self, display_names): + """ + Updates the models of the QComboBox and QCompleter with the current history. + """ + self.model.setStringList(display_names) + + self.name_combo.blockSignals(True) + current_text = self.name_combo.currentText() + self.name_combo.clear() + self.name_combo.addItems(display_names) + self.name_combo.setEditText(current_text) + self.name_combo.blockSignals(False) + + def load_data(self, mru_history: list): + """Loads person names from global tags and combines them with MRU history.""" + self.history.clear() + if mru_history: + # Prevent MRU eviction if history is larger than maxlen + # We take the first N items (most recent) to ensure they fit. + items_to_load = mru_history[:self.history.maxlen] \ + if self.history.maxlen is not None else mru_history + for full_tag in items_to_load: + if full_tag and isinstance(full_tag, str): + self.history.append(full_tag) + + all_tags = [] + if self.main_win and hasattr(self.main_win, 'tag_edit_widget'): + all_tags = self.main_win.tag_edit_widget.available_tags + + if self.region_type == "Pet": + parent_tags_str = APP_CONFIG.get("pet_tags", "Pet") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Pet" + elif self.region_type == "Object": + parent_tags_str = APP_CONFIG.get("object_tags", "Object") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Object" + elif self.region_type == "Landmark": + parent_tags_str = APP_CONFIG.get("landmark_tags", "Landmark") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Landmark" + else: + parent_tags_str = APP_CONFIG.get("person_tags", "Person") + if not parent_tags_str or not parent_tags_str.strip(): + parent_tags_str = "Person" + + person_tag_parents = [p.strip() + '/' for p in parent_tags_str.split(',') + if p.strip()] + + self.name_to_tags_map.clear() + all_person_short_names = set() + + # Combine all available tags with the user's history for a complete list + tags_to_process = set(all_tags) | set(self.history) + + for tag in tags_to_process: + is_valid = False + # Always accept tags explicitly in history + if tag in self.history: + is_valid = True + else: + for parent in person_tag_parents: + if tag.startswith(parent): + is_valid = True + break + + if is_valid: + short_name = tag.split('/')[-1] + if short_name: + all_person_short_names.add(short_name) + if short_name not in self.name_to_tags_map: + self.name_to_tags_map[short_name] = [] + # Ensure no duplicate full tags are added for a short name + if tag not in self.name_to_tags_map[short_name]: + self.name_to_tags_map[short_name].append(tag) + + # The display list is built from history first (for MRU order), + # then supplemented with all other known person names. + display_names = [tag.split('/')[-1] for tag in self.history] + for short_name in sorted(list(all_person_short_names)): + if short_name not in display_names: + display_names.append(short_name) + + self._update_models(display_names) + + def get_history(self) -> list: + """Returns the current history list, sorted by most recent.""" + return list(self.history) + + def clear(self): + """Clears the editor text.""" + self.name_combo.clearEditText() + + +class CommentWidget(QWidget): + """A widget to view and edit the 'user.comment' extended attribute.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.file_paths = [] + self._original_comment = "" + + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + layout.addWidget(QLabel(UITexts.INFO_COMMENT_LABEL)) + + self.comment_edit = QTextEdit() + self.comment_edit.setAcceptRichText(False) + self.comment_edit.setPlaceholderText(UITexts.ENTER_COMMENT) + self.comment_edit.textChanged.connect(self.on_text_changed) + layout.addWidget(self.comment_edit) + + self.btn_apply = QPushButton(UITexts.COMMENT_APPLY_CHANGES) + self.btn_apply.clicked.connect(self.save_comment) + self.btn_apply.hide() + + btn_container_layout = QHBoxLayout() + btn_container_layout.addStretch() + btn_container_layout.addWidget(self.btn_apply) + layout.addLayout(btn_container_layout) + + def set_files(self, file_paths): + """Sets the file paths and loads the comment from the first one.""" + self.file_paths = file_paths if file_paths else [] + self.load_comment() + self.btn_apply.hide() + + def load_comment(self): + """Loads the comment using the XattrManager.""" + self.comment_edit.blockSignals(True) + comment = "" + if self.file_paths: + comment = XattrManager.get_attribute( + self.file_paths[0], XATTR_COMMENT_NAME, "") + self._original_comment = comment + self.comment_edit.setText(comment) + self.comment_edit.blockSignals(False) + + def on_text_changed(self): + """Shows the apply button if the text has changed.""" + self.btn_apply.setVisible( + self.comment_edit.toPlainText() != self._original_comment) + + def save_comment(self): + """Saves the comment using the XattrManager.""" + if not self.file_paths: + return + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + new_comment = self.comment_edit.toPlainText() + value_to_set = new_comment if new_comment.strip() else None + for path in self.file_paths: + XattrManager.set_attribute(path, XATTR_COMMENT_NAME, value_to_set) + self._original_comment = new_comment + self.btn_apply.hide() + except IOError as e: + QMessageBox.critical(self, UITexts.ERROR, str(e)) + finally: + QApplication.restoreOverrideCursor() diff --git a/xmpmanager.py b/xmpmanager.py new file mode 100644 index 0000000..debb66c --- /dev/null +++ b/xmpmanager.py @@ -0,0 +1,168 @@ +""" +XMP Manager Module for Bagheera. + +This module provides a dedicated class for handling XMP metadata, specifically +for reading and writing face region information compliant with the Metadata +Working Group (MWG) standard. It relies on the `exiv2` library for all +metadata operations. + +Classes: + XmpManager: A class with static methods to interact with XMP metadata. + +Dependencies: + - python-exiv2: The Python binding for the exiv2 library. The module will + gracefully handle its absence by disabling its functionality. + - utils.preserve_mtime: A utility to prevent file modification times from + changing during metadata writes. +""" +import os +import re +from utils import preserve_mtime +from metadatamanager import notify_baloo +try: + import exiv2 +except ImportError: + exiv2 = None + + +class XmpManager: + """ + A static class that provides methods to read and write face region data + to and from XMP metadata in image files. + """ + + @staticmethod + def load_faces(path): + """ + Loads face regions from a file's XMP metadata (MWG Regions). + + This method parses the XMP data structure for a `mwg-rs:RegionList`, + extracts all regions of type 'Face', and returns them as a list of + dictionaries. Each dictionary contains the face's name and its + normalized coordinates (center x, center y, width, height). + + Args: + path (str): The path to the image file. + + Returns: + list: A list of dictionaries, where each dictionary represents a face. + Returns an empty list if exiv2 is not available or on error. + """ + if not exiv2 or not path or not os.path.exists(path): + return [] + + faces = [] + try: + img = exiv2.ImageFactory.open(path) + # readMetadata() is crucial to populate the data structures. + img.readMetadata() + xmp = img.xmpData() + + regions = {} + for datum in xmp: + key = datum.key() + if "mwg-rs:RegionList" in key: + # Use regex to find the index of the region in the list, + # e.g., RegionList[1], RegionList[2], etc. + m = re.search(r'RegionList\[(\d+)\]', key) + if m: + idx = int(m.group(1)) + if idx not in regions: + regions[idx] = {} + val = datum.toString() + if key.endswith("/mwg-rs:Name"): + regions[idx]['name'] = val + elif key.endswith("/stArea:x"): + regions[idx]['x'] = float(val) + elif key.endswith("/stArea:y"): + regions[idx]['y'] = float(val) + elif key.endswith("/stArea:w"): + regions[idx]['w'] = float(val) + elif key.endswith("/stArea:h"): + regions[idx]['h'] = float(val) + elif key.endswith("/mwg-rs:Type"): + regions[idx]['type'] = val + + # Convert the structured dictionary into a flat list of faces, + # preserving all regions (including 'Pet', etc.) to avoid data loss. + for idx, data in sorted(regions.items()): + if 'x' in data and 'y' in data and 'w' in data and 'h' in data: + faces.append(data) + except Exception as e: + print(f"Error loading faces from XMP: {e}") + return faces + + @staticmethod + def save_faces(path, faces): + """ + Saves a list of faces to a file's XMP metadata as MWG Regions. + + This method performs a clean write by first removing all existing + face region metadata from the file and then writing the new data. + This method preserves the file's original modification time. + + Args: + path (str): The path to the image file. + faces (list): A list of face dictionaries to save. + + Returns: + bool: True on success, False on failure. + """ + if not exiv2 or not path: + return False + try: + # Register required XMP namespaces to ensure they are recognized. + exiv2.XmpProperties.registerNs( + "http://www.metadataworkinggroup.com/schemas/regions/", "mwg-rs") + exiv2.XmpProperties.registerNs( + "http://ns.adobe.com/xmp/sType/Area#", "stArea") + with preserve_mtime(path): + img = exiv2.ImageFactory.open(path) + img.readMetadata() + xmp = img.xmpData() + + # 1) Remove all existing RegionList entries to prevent conflicts. + keys_to_delete = [ + d.key() for d in xmp + if d.key().startswith("Xmp.mwg-rs.Regions/mwg-rs:RegionList") + ] + for key in sorted(keys_to_delete, reverse=True): + try: + xmp_key = exiv2.XmpKey(key) + it = xmp.findKey(xmp_key) + if it != xmp.end(): + xmp.erase(it) + except Exception: + pass + + # 2) Recreate the RegionList from the provided faces list. + if faces: + # To initialize an XMP list (rdf:Bag), it is necessary to + # register the key as an array before it can be indexed. + # Failing to do so causes the "XMP Toolkit error 102: + # Indexing applied to non-array". A compatible way to do + # this with the python-exiv2 binding is to assign an + # XmpTextValue and specify its type as 'Bag', which + # correctly creates the empty array structure. + if exiv2 and hasattr(exiv2, 'XmpTextValue'): + xmp["Xmp.mwg-rs.Regions/mwg-rs:RegionList"] = \ + exiv2.XmpTextValue("type=Bag") + + for i, face in enumerate(faces): + # The index for XMP arrays is 1-based. + base = f"Xmp.mwg-rs.Regions/mwg-rs:RegionList[{i+1}]" + xmp[f"{base}/mwg-rs:Name"] = face.get('name', 'Unknown') + xmp[f"{base}/mwg-rs:Type"] = face.get('type', 'Face') + area_base = f"{base}/mwg-rs:Area" + xmp[f"{area_base}/stArea:x"] = str(face.get('x', 0)) + xmp[f"{area_base}/stArea:y"] = str(face.get('y', 0)) + xmp[f"{area_base}/stArea:w"] = str(face.get('w', 0)) + xmp[f"{area_base}/stArea:h"] = str(face.get('h', 0)) + xmp[f"{area_base}/stArea:unit"] = 'normalized' + + img.writeMetadata() + notify_baloo(path) + return True + except Exception as e: + print(f"Error saving faces to XMP: {e}") + return False