From 56ef674d4a740de91d0b7929909254a05d0f6f5b Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Wed, 25 Mar 2026 12:18:19 +0100 Subject: [PATCH] v0.9.12 --- bagheeraview.py | 486 +++++++++++++++++++++++++----------------------- changelog.txt | 24 ++- constants.py | 93 ++++++--- imagescanner.py | 11 +- imageviewer.py | 473 +++++++++++++++++++++++++++++----------------- pyproject.toml | 2 +- settings.py | 3 +- setup.py | 2 +- widgets.py | 2 +- 9 files changed, 641 insertions(+), 455 deletions(-) diff --git a/bagheeraview.py b/bagheeraview.py index 6b8cf4b..dc6199c 100755 --- a/bagheeraview.py +++ b/bagheeraview.py @@ -14,7 +14,7 @@ Classes: MainWindow: The main application window containing the thumbnail grid and docks. """ __appname__ = "BagheeraView" -__version__ = "0.9.11" +__version__ = "0.9.12" __author__ = "Ignacio Serantes" __email__ = "kde@aynoa.net" __license__ = "LGPL" @@ -41,10 +41,10 @@ from PySide6.QtWidgets import ( from PySide6.QtGui import ( QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette, QStandardItemModel, QStandardItem, QColor, QPixmap, QPixmapCache, QPainter, - QKeySequence, QAction, QActionGroup + QKeySequence, QAction, QActionGroup, QImage ) from PySide6.QtCore import ( - Qt, QPoint, QUrl, QObject, QEvent, QTimer, QMimeData, QByteArray, + Qt, QPoint, QUrl, QObject, QEvent, QTimer, QByteArray, QItemSelection, QSortFilterProxyModel, QItemSelectionModel, QRect, QSize, QThread, QPersistentModelIndex, QModelIndex ) @@ -1483,9 +1483,9 @@ class MainWindow(QMainWindow): 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 "geometry" in mw_data: + # g = mw_data["geometry"] + # self.setGeometry(g["x"], g["y"], g["w"], g["h"]) selected_path = mw_data.get("selected_path") select_paths = [selected_path] if selected_path else None @@ -2621,242 +2621,247 @@ class MainWindow(QMainWindow): # 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() + selected_paths = [] + try: + index = self.view_mode_combo.currentIndex() - self._model_update_queue.clear() - self._model_update_timer.stop() + 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) + # 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)) + 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_paths = self.get_selected_paths() + # Preserve selection + selected_paths = self.get_selected_paths() - mode = self.sort_combo.currentText() - rev = "↓" in mode - sort_by_name = "Name" in mode + 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. + # 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 + # 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) + 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) + # 3. Rebuild the model. Disable view updates for a massive performance + # boost. + self.thumbnail_view.setUpdatesEnabled(False) - target_structure = [] + 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. + 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) + 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: + # 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) + + 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) - 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) + 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) + except Exception as e: + import traceback + traceback.print_exc() + print(f"Error in rebuild_view: {e}") + finally: self._suppress_updates = False self.apply_filters() self.thumbnail_view.setUpdatesEnabled(True) - self.restore_selection(selected_paths) + if selected_paths: + self.restore_selection(selected_paths) 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.restore_selection(selected_paths) - - 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): @@ -3452,8 +3457,6 @@ class MainWindow(QMainWindow): self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None)) self.rebuild_view(full_reset=True) - self.update_tag_list() - self.save_config() self.setFocus() @@ -3954,9 +3957,15 @@ class MainWindow(QMainWindow): 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_image = clipboard_menu.addAction(QIcon.fromTheme("image-x-generic"), + UITexts.VIEWER_MENU_COPY_IMAGE) + action_copy_image.triggered.connect(self.copy_image_to_clipboard) + if len(selected_indexes) > 1: + action_copy_image.setEnabled(False) + + action_copy_path = clipboard_menu.addAction( + QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH) + action_copy_path.triggered.connect(self.copy_file_path_to_clipboard) action_copy_dir = clipboard_menu.addAction(QIcon.fromTheme("folder"), UITexts.CONTEXT_MENU_COPY_DIR) @@ -4127,23 +4136,30 @@ class MainWindow(QMainWindow): 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.""" + def copy_image_to_clipboard(self): + """Copies the full image of the selected thumbnail 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) + # This is a disk read, but it's on user action. + img = QImage(path) + if not img.isNull(): + QApplication.clipboard().setImage(img) + + def copy_file_path_to_clipboard(self): + """Copies the file path(s) of the selected image(s) to the clipboard.""" + paths = self.get_selected_paths() + if not paths: + return + QApplication.clipboard().setText("\n".join(paths)) 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: + """Copies the directory path(s) of the selected image(s) to the clipboard.""" + paths = self.get_selected_paths() + if not paths: return - QApplication.clipboard().setText(os.path.dirname(path)) + dir_paths = sorted(list(set(os.path.dirname(p) for p in paths))) + QApplication.clipboard().setText("\n".join(dir_paths)) def show_properties(self): """Shows the custom properties dialog for the selected file.""" diff --git a/changelog.txt b/changelog.txt index 289f046..24f90e4 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,12 +1,10 @@ -v0.9.11 - -· Filmstrip fixed -· Añadida una nueva área llamada Body. -· Refactorizaciones, optimizaciones y cambios a saco. -· Image viewer tiene comparisonb - Implement a bulk rename feature for the selected pet or face tags. Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text. +Me gustaría que el submenú de "Manipular" también incluyera una opción para "Escalar" la imagen, ¿cómo podría implementarlo? + +Ensure that apply_filters doesn't trigger redundant updates if rebuild_view already called it. + Add a visual indicator (e.g., an icon in the status bar) for the "Link Panes" status instead of text. Add a `shutdown` signal or method to `ScannerWorker` to allow cleaner cancellation of long-running tasks like `generate_thumbnail`. Implement a mechanism to dynamically adjust the thread pool size based on system load or user activity. @@ -30,10 +28,6 @@ Implement a "Comparison" mode to view 2 or 4 images side-by-side in the viewer. · 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. -¿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? - -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. @@ -69,6 +63,16 @@ Ahora que la carga es rápida, ¿cómo puedo implementar una precarga inteligent ¿Cómo puedo hacer que la selección de archivos sea persistente incluso después de recargar o filtrar la vista? +v0.9.12 - +· Al restaurar el layout no se restaura la posición y dimensiones de los thumbnails. +· Mejoras en los menús de contexto. + +v0.9.11 - +· Filmstrip fixed +· Añadida una nueva área llamada Body. +· Refactorizaciones, optimizaciones y cambios a saco. +· Image viewer tiene comparisonb + v0.9.10 - Eleven step to 1.0 · Slideshow inverso · Más mejoras de rendimiento y seguridad diff --git a/constants.py b/constants.py index 4e0de24..b2675cc 100644 --- a/constants.py +++ b/constants.py @@ -29,7 +29,7 @@ if FORCE_X11: # --- CONFIGURATION --- PROG_NAME = "Bagheera Image Viewer" PROG_ID = "bagheeraview" -PROG_VERSION = "0.9.11" +PROG_VERSION = "0.9.12" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- @@ -96,7 +96,12 @@ KWINOUTPUTCONFIG_PATH = os.path.join(os.path.expanduser("~"), # --- EXTERNAL TOOLS --- # Command definitions for external search tools. - +try: + from bagheera_search_lib import BagheeraSearcher + HAVE_BAGHEERASEARCH_LIB = True +except ImportError: + HAVE_BAGHEERASEARCH_LIB = False + pass BALOOSEARCH_EXEC = shutil.which("baloosearch") or shutil.which("baloosearch6") SEARCH_CMD = [BALOOSEARCH_EXEC, "--type", "image"] if BALOOSEARCH_EXEC else None @@ -167,8 +172,6 @@ if importlib.util.find_spec("mediapipe") is not None: pass HAVE_FACE_RECOGNITION = importlib.util.find_spec("face_recognition") is not None -HAVE_BAGHEERASEARCH_LIB = True - MEDIAPIPE_FACE_MODEL_PATH = os.path.join(CONFIG_DIR, "blaze_face_short_range.tflite") MEDIAPIPE_FACE_MODEL_URL = ( @@ -291,6 +294,8 @@ VIEWER_ACTIONS = { "toggle_visibility": ("Show/Hide Main Window", "Window"), "toggle_crop": ("Toggle Crop Mode", "Edit"), "save_crop": ("Save Cropped Image", "File"), + "copy_image": ("Copy Image to Clipboard", "Edit"), + "copy_path": ("Copy File Path", "Edit"), "compare_1": ("Single View", "View"), "compare_2": ("Compare 2 Images", "View"), "compare_4": ("Compare 4 Images", "View"), @@ -451,8 +456,8 @@ _UI_TEXTS = { "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.", + "for KDE with Baloo support.

Created by {2} with the help of AI, but " + "mostly thanks to the job of 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", @@ -527,7 +532,7 @@ _UI_TEXTS = { "SETTINGS_THUMBS_FILENAME_FONT_SIZE_LABEL": "Thumbnails filename font size:", "SETTINGS_THUMBS_TAGS_FONT_SIZE_LABEL": "Thumbnails tags font size:", "SETTINGS_SCAN_THREADS_LABEL": "Generation threads:", - "SETTINGS_SCAN_THREADS_TOOLTIP": "Maximum number of simultaneous threads to" + "SETTINGS_SCAN_THREADS_TOOLTIP": "Maximum number of simultaneous threads to " "generate thumbnails.", "SETTINGS_SCAN_MAX_LEVEL_LABEL": "Scan Max Level:", "SETTINGS_SCAN_BATCH_SIZE_LABEL": "Scan Batch Size:", @@ -742,6 +747,15 @@ _UI_TEXTS = { "VIEWER_MENU_TAGS": "Quick tags", "VIEWER_MENU_CROP": "Crop Mode", "VIEWER_MENU_SAVE_CROP": "Save Selection...", + "VIEWER_MENU_COPY_PATH": "Copy File Path", + "VIEWER_MENU_COPY_IMAGE": "Copy Image to Clipboard", + "VIEWER_MENU_DETECT_AREAS": "Detect areas", + "VIEWER_MENU_DETECT_FACES": "Faces", + "VIEWER_MENU_DETECT_PETS": "Pets", + "VIEWER_MENU_MANIPULATE": "Manipulate", + "VIEWER_MENU_ZOOM": "Zoom", + "VIEWER_MENU_ZOOM_IN": "Zoom In", + "VIEWER_MENU_ZOOM_OUT": "Zoom Out", "SAVE_CROP_TITLE": "Save Cropped Image", "VIEWER_MENU_COMPARE": "Comparison Mode", "VIEWER_MENU_COMPARE_1": "Single View", @@ -762,7 +776,7 @@ _UI_TEXTS = { "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_FILE": "Copy File URL", "CONTEXT_MENU_COPY_DIR": "Copy Directory Path", "CONTEXT_MENU_PROPERTIES": "Properties", "CONTEXT_MENU_NO_APPS_FOUND": "No apps found", @@ -881,7 +895,7 @@ _UI_TEXTS = { "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.", + "la IA, pero mayormente gracias al trabajo de 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", @@ -898,11 +912,11 @@ _UI_TEXTS = { "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_MRU_TAGS_COUNT_LABEL": "Máximo número de 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_HISTORY_COUNT_LABEL": "Máximo historial de mascotas:", "SETTINGS_PET_TAGS_TOOLTIP": "Etiquetas predeterminadas para mascotas, " "separadas por comas.", "SETTINGS_PET_ENGINE_TOOLTIP": "Librería utilizada para la detección de " @@ -919,7 +933,7 @@ _UI_TEXTS = { "SETTINGS_BODY_TAGS_LABEL": "Etiquetas de cuerpo:", "SETTINGS_BODY_ENGINE_LABEL": "Motor de detección de cuerpos:", "SETTINGS_BODY_COLOR_LABEL": "Color del recuadro de cuerpo:", - "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máx historial cuerpos:", + "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máximo historial de cuerpos:", "SETTINGS_BODY_TAGS_TOOLTIP": "Etiquetas predeterminadas para cuerpos, " "separadas por comas.", "SETTINGS_BODY_ENGINE_TOOLTIP": "Librería utilizada para la detección de " @@ -931,7 +945,7 @@ _UI_TEXTS = { "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_HISTORY_COUNT_LABEL": "Máximo historial de objetos:", "SETTINGS_OBJECT_TAGS_TOOLTIP": "Etiquetas predeterminadas para objetos, " "separadas por comas.", "SETTINGS_OBJECT_ENGINE_TOOLTIP": "Librería utilizada para la detección " @@ -943,7 +957,7 @@ _UI_TEXTS = { "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_HISTORY_COUNT_LABEL": "Máximo historial de lugares:", "SETTINGS_LANDMARK_TAGS_TOOLTIP": "Etiquetas predeterminadas para " "lugares/monumentos, separadas por comas.", "SETTINGS_LANDMARK_ENGINE_TOOLTIP": "Librería utilizada para la detección " @@ -952,7 +966,7 @@ _UI_TEXTS = { "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_FACE_HISTORY_COUNT_LABEL": "Máximo historial de 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:", @@ -960,12 +974,15 @@ _UI_TEXTS = { "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_SCAN_THREADS_LABEL": "Hilos de generación:", + "SETTINGS_SCAN_THREADS_TOOLTIP": "Número máximo de hilos simultaneos para " + "generar miniaturas.", "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. " - "'Bagheera' usa la librería de BagheeraSearch. 'Baloo0 usa el commando " + "'Bagheera' usa la librería de BagheeraSearch. 'Baloo' usa el commando " "'baloosearch'", "SETTINGS_SCAN_FULL_ON_START_LABEL": "Escanear Todo al Inicio:", "SETTINGS_SCAN_MAX_LEVEL_TOOLTIP": "Profundidad máxima de directorio para " @@ -1177,6 +1194,15 @@ _UI_TEXTS = { "VIEWER_MENU_TAGS": "Etiquetas rápidas", "VIEWER_MENU_CROP": "Modo Recorte", "VIEWER_MENU_SAVE_CROP": "Guardar Selección...", + "VIEWER_MENU_COPY_PATH": "Copiar Ruta del Archivo", + "VIEWER_MENU_COPY_IMAGE": "Copiar Imagen al Portapapeles", + "VIEWER_MENU_DETECT_AREAS": "Detectar áreas", + "VIEWER_MENU_DETECT_FACES": "Rostros", + "VIEWER_MENU_DETECT_PETS": "Mascotas", + "VIEWER_MENU_MANIPULATE": "Manipular", + "VIEWER_MENU_ZOOM": "Zoom", + "VIEWER_MENU_ZOOM_IN": "Acercar", + "VIEWER_MENU_ZOOM_OUT": "Alejar", "VIEWER_MENU_COMPARE": "Modo Comparación", "VIEWER_MENU_COMPARE_1": "Vista Única", "VIEWER_MENU_COMPARE_2": "2 Imágenes", @@ -1197,7 +1223,7 @@ _UI_TEXTS = { "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_FILE": "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", @@ -1316,7 +1342,7 @@ _UI_TEXTS = { "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.", + " IA, pero maiormente gracias ó traballo da boa xente de KDE e Qt.", "MENU_CACHE": "Caché", "MENU_CLEAR_CACHE": "Limpar caché ({} elementos, {:.1f} MB, {:.1f} MB en " "disco)", @@ -1328,17 +1354,17 @@ _UI_TEXTS = { "MENU_SHOW_HISTORY": "Amosar Historial", "MENU_SETTINGS": "Opcións", "SETTINGS_GROUP_SCANNER": "Escáner", - "SETTINGS_GROUP_AREAS": "´áreas", + "SETTINGS_GROUP_AREAS": "Á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_MRU_TAGS_COUNT_LABEL": "Máximo número de 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_HISTORY_COUNT_LABEL": "Máximo historial de mascotas:", "SETTINGS_PET_TAGS_TOOLTIP": "Etiquetas predeterminadas para mascotas, " "separadas por comas.", "SETTINGS_PET_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " @@ -1355,7 +1381,7 @@ _UI_TEXTS = { "SETTINGS_BODY_TAGS_LABEL": "Etiquetas de corpo:", "SETTINGS_BODY_ENGINE_LABEL": "Motor de detección de corpos:", "SETTINGS_BODY_COLOR_LABEL": "Cor do cadro de corpo:", - "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máx historial corpos:", + "SETTINGS_BODY_HISTORY_COUNT_LABEL": "Máximo historial de corpos:", "SETTINGS_BODY_TAGS_TOOLTIP": "Etiquetas predeterminadas para corpos, " "separadas por comas.", "SETTINGS_BODY_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " @@ -1367,7 +1393,7 @@ _UI_TEXTS = { "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_HISTORY_COUNT_LABEL": "Máximo historial de obxectos:", "SETTINGS_OBJECT_TAGS_TOOLTIP": "Etiquetas predeterminadas para obxectos, " "separadas por comas.", "SETTINGS_OBJECT_ENGINE_TOOLTIP": "Libraría utilizada para a detección de " @@ -1379,7 +1405,7 @@ _UI_TEXTS = { "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_HISTORY_COUNT_LABEL": "Máximo historial de lugares:", "SETTINGS_LANDMARK_TAGS_TOOLTIP": "Etiquetas predeterminadas para " "lugares/monumentos, separadas por comas.", "SETTINGS_LANDMARK_ENGINE_TOOLTIP": "Libraría utilizada para a detección " @@ -1388,7 +1414,7 @@ _UI_TEXTS = { "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_FACE_HISTORY_COUNT_LABEL": "Máximo historial de 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:", @@ -1396,10 +1422,10 @@ _UI_TEXTS = { "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_SCAN_THREADS_LABEL": "Fios de xeración:", + "SETTINGS_SCAN_THREADS_TOOLTIP": "Número máximo de fios simultaneos para " + "xerar miniaturas.", "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:", @@ -1615,6 +1641,15 @@ _UI_TEXTS = { "VIEWER_MENU_TAGS": "Etiquetas rápidas", "VIEWER_MENU_CROP": "Modo Recorte", "VIEWER_MENU_SAVE_CROP": "Gardar Selección...", + "VIEWER_MENU_COPY_PATH": "Copiar Ruta do Ficheiro", + "VIEWER_MENU_COPY_IMAGE": "Copiar Imaxe ao Portapapeis", + "VIEWER_MENU_DETECT_AREAS": "Detectar áreas", + "VIEWER_MENU_DETECT_FACES": "Rostros", + "VIEWER_MENU_DETECT_PETS": "Mascotas", + "VIEWER_MENU_MANIPULATE": "Manipular", + "VIEWER_MENU_ZOOM": "Zoom", + "VIEWER_MENU_ZOOM_IN": "Achegar", + "VIEWER_MENU_ZOOM_OUT": "Afastar", "VIEWER_MENU_COMPARE": "Modo Comparación", "VIEWER_MENU_COMPARE_1": "Vista Única", "VIEWER_MENU_COMPARE_2": "2 Imaxes", @@ -1636,7 +1671,7 @@ _UI_TEXTS = { "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_FILE": "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", diff --git a/imagescanner.py b/imagescanner.py index 6824015..0739b9c 100644 --- a/imagescanner.py +++ b/imagescanner.py @@ -36,19 +36,16 @@ 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, HAVE_BAGHEERASEARCH_LIB + HAVE_BAGHEERASEARCH_LIB, IMAGE_EXTENSIONS, RATING_XATTR_NAME, + SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, THUMBNAIL_SIZES, XATTR_NAME, + UITexts ) from imageviewer import ImageViewer from metadatamanager import XattrManager if HAVE_BAGHEERASEARCH_LIB: - try: - from bagheera_search_lib import BagheeraSearcher - except ImportError: - HAVE_BAGHEERASEARCH_LIB = False - pass + from bagheera_search_lib import BagheeraSearcher # Set up logging for better debugging logger = logging.getLogger(__name__) diff --git a/imageviewer.py b/imageviewer.py index ab638ac..a0af76e 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -172,14 +172,14 @@ class FastTagManager: 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(): + controller = self.viewer.controller + if not self.main_win or not controller or not controller.get_current_path(): return - current_path = self.controller.get_current_path() + current_path = 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()} @@ -233,10 +233,11 @@ class FastTagManager: return tag_name = action.text() is_checked = action.isChecked() - current_path = self.controller.get_current_path() + controller = self.viewer.controller + current_path = controller.get_current_path() if controller else None if not current_path: return - self.controller.toggle_tag(tag_name, is_checked) + controller.toggle_tag(tag_name, is_checked) self.viewer.update_status_bar() if self.main_win: if is_checked: @@ -289,18 +290,23 @@ class FilmStripWidget(QListWidget): 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): + def __init__(self, viewer, parent=None): """ Initializes the FilmStripWidget. Args: - controller (ImageController): The controller managing the image list. + viewer (ImageViewer): The viewer that owns this filmstrip. parent (QWidget, optional): The parent widget. Defaults to None. """ super().__init__(parent) - self.controller = controller + self.viewer = viewer self.setDragEnabled(True) + @property + def controller(self): + """Returns the controller of the active pane in the viewer.""" + return self.viewer.controller + def startDrag(self, supportedActions): """ Initiates a drag-and-drop operation for the selected image(s). @@ -312,7 +318,7 @@ class FilmStripWidget(QListWidget): urls = [] for item in items: row = self.row(item) - if 0 <= row < len(self.controller.image_list): + if self.controller and 0 <= row < len(self.controller.image_list): path = self.controller.image_list[row] urls.append(QUrl.fromLocalFile(path)) @@ -332,6 +338,63 @@ class FilmStripWidget(QListWidget): drag.exec(Qt.CopyAction) + def _get_selected_paths(self): + """Helper to get paths of all selected items.""" + return [item.data(Qt.UserRole) + for item in self.selectedItems() if item.data(Qt.UserRole)] + + def contextMenuEvent(self, event): + """Shows a context menu for the selected items.""" + selected_items = self.selectedItems() + if not selected_items: + return + + menu = QMenu(self) + + # Clipboard Submenu + clipboard_menu = menu.addMenu( + QIcon.fromTheme("edit-copy"), UITexts.CONTEXT_MENU_CLIPBOARD) + + # Copy Image + action_copy_image = clipboard_menu.addAction( + QIcon.fromTheme("image-x-generic"), UITexts.VIEWER_MENU_COPY_IMAGE) + action_copy_image.triggered.connect(self._copy_image_to_clipboard) + if len(selected_items) > 1: + action_copy_image.setEnabled(False) + + # Copy Path + action_copy_path = clipboard_menu.addAction( + QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH) + action_copy_path.triggered.connect(self._copy_path_to_clipboard) + + # Copy Directory Path + action_copy_dir = clipboard_menu.addAction( + QIcon.fromTheme("folder"), UITexts.CONTEXT_MENU_COPY_DIR) + action_copy_dir.triggered.connect(self._copy_dir_to_clipboard) + + menu.exec(event.globalPos()) + + def _copy_image_to_clipboard(self): + """Copies the currently selected image to the system clipboard.""" + paths = self._get_selected_paths() + if len(paths) == 1 and paths[0] and os.path.exists(paths[0]): + img = QImage(paths[0]) + if not img.isNull(): + QApplication.clipboard().setImage(img) + + def _copy_path_to_clipboard(self): + """Copies the file path(s) of the selected image(s) to the clipboard.""" + paths = self._get_selected_paths() + if paths: + QApplication.clipboard().setText("\n".join(paths)) + + def _copy_dir_to_clipboard(self): + """Copies the directory path(s) of the selected image(s) to the clipboard.""" + paths = self._get_selected_paths() + if paths: + dir_paths = sorted(list(set(os.path.dirname(p) for p in paths))) + QApplication.clipboard().setText("\n".join(dir_paths)) + class FaceCanvas(QLabel): """ @@ -1222,8 +1285,8 @@ class ImagePane(QWidget): 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.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scroll_area.setWidget(self.canvas) layout.addWidget(self.scroll_area) @@ -1413,7 +1476,7 @@ class ImageViewer(QWidget): # self.canvas = FaceCanvas(self) ... Moved to ImagePane # self.scroll_area.setWidget(self.canvas) - self.filmstrip = FilmStripWidget(self.controller) + self.filmstrip = FilmStripWidget(self) self.filmstrip.setSpacing(2) self.filmstrip.itemClicked.connect(self.on_filmstrip_clicked) @@ -1564,7 +1627,6 @@ class ImageViewer(QWidget): pane.scrolled.connect(self._sync_scroll) pane.zoom_manager.zoomed.connect(self._sync_zoom) - self.filmstrip.controller = pane.controller self.populate_filmstrip() self.sync_filmstrip_selection(pane.controller.index) self.update_status_bar() @@ -1618,6 +1680,10 @@ class ImageViewer(QWidget): if count == current_panes: return + if count > 1 and self.slideshow_manager.is_running(): + self.slideshow_manager.stop() + self.update_title() + if count > current_panes: # Add panes base_controller = self.active_pane.controller @@ -1726,6 +1792,9 @@ class ImageViewer(QWidget): "properties": self.show_properties, "toggle_visibility": self.toggle_main_window_visibility, "toggle_crop": self.toggle_crop_mode, + "copy_image": self.copy_image_to_clipboard, + "copy_path": self.copy_file_path_to_clipboard, + "copy_dir_path": self.copy_dir_path_to_clipboard, "save_crop": self.save_cropped_image, "compare_1": lambda: self.set_comparison_mode(1), "compare_2": lambda: self.set_comparison_mode(2), @@ -1735,6 +1804,11 @@ class ImageViewer(QWidget): def _execute_action(self, action): """Executes the method corresponding to the action name.""" + if self.slideshow_manager.is_running(): + allowed_actions = ('slideshow', 'slideshow_reverse', 'close', 'fullscreen') + if action not in allowed_actions: + return + if action in self._actions: self._actions[action]() @@ -2383,6 +2457,81 @@ class ImageViewer(QWidget): if self.main_win: self.main_win.update_metadata_for_path(path, metadata) + def copy_image_to_clipboard(self): + """Copies the currently displayed image to the system clipboard.""" + if self.controller and not self.controller.pixmap_original.isNull(): + QApplication.clipboard().setImage(self.controller.pixmap_original.toImage()) + + def copy_file_path_to_clipboard(self): + """Copies the current image's file path to the system clipboard.""" + if self.controller: + path = self.controller.get_current_path() + if path: + QApplication.clipboard().setText(path) + + def copy_dir_path_to_clipboard(self): + """Copies the directory path of the current image to the system clipboard.""" + if self.controller: + path = self.controller.get_current_path() + if path: + QApplication.clipboard().setText(os.path.dirname(path)) + + def _populate_open_with_menu(self, menu): + """Populates the 'Open With' submenu.""" + if self.main_win: + path = self.controller.get_current_path() + if path: + self.main_win.populate_open_with_submenu(menu, path) + + def _build_menu_from_data(self, target_menu, items): + """Builds a menu or submenu from a list of dictionary items.""" + for item in items: + if item == "separator": + target_menu.addSeparator() + continue + + # Handle dynamic submenus that need to be populated by a function + if "dynamic_submenu" in item: + icon = QIcon.fromTheme(item.get("icon", "")) + submenu = target_menu.addMenu(icon, item["text"]) + item["dynamic_submenu"](submenu) + continue + + action_name = item.get("action") + display_text = item["text"] + + # Add shortcut string to display text if available + if action_name and "submenu" not in item and \ + action_name in self.action_to_shortcut: + key, mods = self.action_to_shortcut[action_name] + 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"]) + self._build_menu_from_data(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)) + def restore_scroll_for_pane(self, pane, config): """ Applies the saved scrollbar positions from a layout configuration. @@ -2451,11 +2600,15 @@ class ImageViewer(QWidget): def toggle_slideshow(self): """Starts or stops the automatic slideshow timer.""" + if len(self.panes) > 1: + return self.slideshow_manager.toggle(reverse=False) self.update_view(resize_win=False) def toggle_slideshow_reverse(self): """Starts or stops the automatic reverse slideshow timer.""" + if len(self.panes) > 1: + return self.slideshow_manager.toggle(reverse=True) self.update_view(resize_win=False) @@ -2604,168 +2757,148 @@ class ImageViewer(QWidget): """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_structure = [] + if self.slideshow_manager.is_running(): + is_fwd = self.slideshow_manager.is_forward() + is_rev = self.slideshow_manager.is_reverse() + menu_structure = [ + {"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd + else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow", + "icon": "media-playback-stop" if is_fwd else "media-playback-start"}, + {"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev + else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW, + "action": "slideshow_reverse", + "icon": "media-playback-stop" if is_rev else "media-seek-backward"}, + {"text": UITexts.VIEWER_MENU_SET_INTERVAL, + "slot": self.set_slideshow_interval, "icon": "preferences-system-time"} + ] - 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.DETECT_BODIES, "action": "detect_bodies", - "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.zoom_manager.toggle_fit_to_screen, - "icon": "zoom-fit-best"}, - "separator", - {"text": UITexts.VIEWER_MENU_CROP, "action": "toggle_crop", - "icon": "transform-crop", "checkable": True}, # checked updated later - "separator", - {"text": UITexts.VIEWER_MENU_COMPARE, "icon": "view-grid", "submenu": [ - {"text": UITexts.VIEWER_MENU_COMPARE_1, - "action": "compare_1", "icon": "view-restore"}, - {"text": UITexts.VIEWER_MENU_COMPARE_2, - "action": "compare_2", "icon": "view-split-left-right"}, - {"text": UITexts.VIEWER_MENU_COMPARE_4, - "action": "compare_4", "icon": "view-grid"}, + else: + # Build the normal menu structure + is_fwd = self.slideshow_manager.is_forward() + is_rev = self.slideshow_manager.is_reverse() + + slideshow_submenu = [ + {"text": UITexts.VIEWER_MENU_STOP_SLIDESHOW if is_fwd + else UITexts.VIEWER_MENU_START_SLIDESHOW, "action": "slideshow", + "icon": "media-playback-stop" if is_fwd else "media-playback-start"}, + {"text": UITexts.VIEWER_MENU_STOP_REVERSE_SLIDESHOW if is_rev + else UITexts.VIEWER_MENU_START_REVERSE_SLIDESHOW, + "action": "slideshow_reverse", + "icon": "media-playback-stop" if is_rev else "media-seek-backward"}, + {"text": UITexts.VIEWER_MENU_SET_INTERVAL, + "slot": self.set_slideshow_interval, "icon": "preferences-system-time"} + ] + + menu_structure = [ + {"text": UITexts.CONTEXT_MENU_OPEN, "icon": "document-open", + "dynamic_submenu": self._populate_open_with_menu}, "separator", - {"text": UITexts.VIEWER_MENU_LINK_PANES, - "action": "link_panes", "icon": "object-link", - "checkable": True, "checked": self.panes_linked} - ]}, - "separator", - ] + {"text": UITexts.VIEWER_MENU_TAGS, + "action": "fast_tag", "icon": "document-properties"}, + {"text": UITexts.VIEWER_MENU_DETECT_AREAS, + "icon": "edit-image-face-recognize", "submenu": [ + {"text": UITexts.VIEWER_MENU_DETECT_FACES, + "action": "detect_faces"}, + {"text": UITexts.VIEWER_MENU_DETECT_PETS, "action": "detect_pets"}, + ]}, + "separator", + {"text": UITexts.VIEWER_MENU_MANIPULATE, + "icon": "transform", "submenu": [ + {"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"} + ]}, + {"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"} + ]} + ]}, + {"text": UITexts.VIEWER_MENU_ZOOM, "icon": "zoom", "submenu": [ + {"text": UITexts.VIEWER_MENU_ZOOM_IN, + "action": "zoom_in", "icon": "zoom-in"}, + {"text": UITexts.VIEWER_MENU_ZOOM_OUT, + "action": "zoom_out", "icon": "zoom-out"}, + "separator", + {"text": UITexts.VIEWER_MENU_FIT_SCREEN, + "slot": self.zoom_manager.toggle_fit_to_screen, + "icon": "zoom-fit-best"}, + ]}, + "separator", + {"text": UITexts.VIEWER_MENU_RENAME, + "action": "rename", "icon": "edit-rename"}, + {"text": UITexts.CONTEXT_MENU_CLIPBOARD, + "icon": "edit-copy", "submenu": [ + {"text": UITexts.VIEWER_MENU_COPY_IMAGE, + "action": "copy_image", "icon": "image-x-generic"}, + {"text": UITexts.VIEWER_MENU_COPY_PATH, + "action": "copy_path", "icon": "document-properties"}, + {"text": UITexts.CONTEXT_MENU_COPY_DIR, + "action": "copy_dir_path", "icon": "folder"}, + ]}, + {"text": UITexts.VIEWER_MENU_CROP, + "action": "toggle_crop", "icon": "transform-crop", "checkable": True, + "checked": self.crop_mode}, + "separator", + {"text": UITexts.VIEWER_MENU_COMPARE, "icon": "view-grid", "submenu": [ + {"text": UITexts.VIEWER_MENU_COMPARE_1, + "action": "compare_1", "icon": "view-restore"}, + {"text": UITexts.VIEWER_MENU_COMPARE_2, + "action": "compare_2", "icon": "view-split-left-right"}, + {"text": UITexts.VIEWER_MENU_COMPARE_4, + "action": "compare_4", "icon": "view-grid"}, + "separator", + {"text": UITexts.VIEWER_MENU_LINK_PANES, + "action": "link_panes", "icon": "object-link", + "checkable": True, "checked": self.panes_linked} + ]}, + "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}) + 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_structure.append( + {"text": pause_text, "action": "toggle_animation", + "icon": pause_icon}) - is_fwd_slideshow = self.slideshow_manager.is_forward() - is_rev_slideshow = self.slideshow_manager.is_reverse() + menu_structure.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 if self.controller else False}, + {"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"} + ]) - 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) + self._build_menu_from_data(menu, menu_structure) return menu diff --git a/pyproject.toml b/pyproject.toml index 2fb0c0a..e0dc3e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bagheeraview" -version = "0.9.11" +version = "0.9.12" authors = [ { name = "Ignacio Serantes" } ] diff --git a/settings.py b/settings.py index 2553831..f1ca725 100644 --- a/settings.py +++ b/settings.py @@ -27,6 +27,7 @@ from constants import ( FACES_MENU_MAX_ITEMS_DEFAULT, MEDIAPIPE_FACE_MODEL_PATH, MEDIAPIPE_FACE_MODEL_URL, AVAILABLE_PET_ENGINES, DEFAULT_BODY_BOX_COLOR, MEDIAPIPE_OBJECT_MODEL_PATH, MEDIAPIPE_OBJECT_MODEL_URL, + HAVE_BAGHEERASEARCH_LIB, SCANNER_SETTINGS_DEFAULTS, SEARCH_CMD, TAGS_MENU_MAX_ITEMS_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT, THUMBNAILS_BG_COLOR_DEFAULT, @@ -35,7 +36,7 @@ from constants import ( 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, HAVE_BAGHEERASEARCH_LIB + UITexts, save_app_config ) diff --git a/setup.py b/setup.py index 9a56bc3..bb62ef0 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="bagheeraview", - version="0.9.11", + version="0.9.12", 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 " diff --git a/widgets.py b/widgets.py index 495c198..3ac8b42 100644 --- a/widgets.py +++ b/widgets.py @@ -398,7 +398,7 @@ class TagEditWidget(QWidget): if not full_path: return "" words = full_path.replace('/', ' ').split() - search_terms = [f"tags:{word}" for word in words if word] + search_terms = [f"tags:'{word}'" for word in words if word] return " ".join(search_terms) def _get_current_query_text(self):