This commit is contained in:
Ignacio Serantes
2026-03-25 12:18:19 +01:00
parent 0349155fd2
commit 56ef674d4a
9 changed files with 641 additions and 455 deletions

View File

@@ -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,6 +2621,8 @@ class MainWindow(QMainWindow):
# layout properties (grid size, uniform items) are in sync with the
# selected mode before any model rebuild.
self._suppress_updates = True
selected_paths = []
try:
index = self.view_mode_combo.currentIndex()
self._model_update_queue.clear()
@@ -2661,15 +2663,16 @@ class MainWindow(QMainWindow):
self.found_items_data.sort(key=user_sort_key, reverse=rev)
# 3. Rebuild the model. Disable view updates for a massive performance boost.
# 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.
# 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
@@ -2694,18 +2697,9 @@ class MainWindow(QMainWindow):
self._path_to_model_index[path] = \
QPersistentModelIndex(source_index)
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()
return
else:
# For Grouped View, we must ensure source model order matches groups/headers
# For Grouped View, we must ensure source model order matches
# groups/headers
self.proxy_model.sort(-1) # Disable proxy sorting
if full_reset:
@@ -2784,18 +2778,21 @@ class MainWindow(QMainWindow):
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
# 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)
# 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':
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)
@@ -2811,25 +2808,27 @@ class MainWindow(QMainWindow):
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.
# 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)
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)
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)
@@ -2841,16 +2840,22 @@ class MainWindow(QMainWindow):
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
# 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)
if selected_paths:
self.restore_selection(selected_paths)
if self.main_dock.isVisible() and \
@@ -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."""

View File

@@ -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

View File

@@ -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": "<b>{0}</b> v{1}<br><br>A simple image viewer and manager "
"for KDE with Baloo support.<br><br>Created by {2} with the help of AI, and "
"mostly the good people at KDE and Qt.",
"for KDE with Baloo support.<br><br>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": "<b>{0}</b> v{1}<br><br>Un visor y gestor de imágenes "
"simple para KDE con soporte para Baloo.<br><br>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": "<b>{0}</b> v{1}<br><br>Un visor e xestor de imaxes "
"sinxelo para KDE con soporte para Baloo.<br><br>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",

View File

@@ -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
# Set up logging for better debugging
logger = logging.getLogger(__name__)

View File

@@ -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,53 +2757,94 @@ 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"},
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.DETECT_FACES, "action": "detect_faces",
"icon": "edit-image-face-recognize"},
{"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.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_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"}
]},
"separator",
{"text": UITexts.VIEWER_MENU_FLIP, "icon": "transform-flip", "submenu": [
{"text": UITexts.VIEWER_MENU_FLIP,
"icon": "transform-flip", "submenu": [
{"text": UITexts.VIEWER_MENU_FLIP_H,
"action": "flip_horizontal",
"icon": "object-flip-horizontal"},
"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"},
{"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_CROP, "action": "toggle_crop",
"icon": "transform-crop", "checkable": True}, # checked updated later
{"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,
@@ -2668,104 +2862,43 @@ class ImageViewer(QWidget):
]
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",
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()
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},
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},
{"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()},
"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()},
"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"},
"action": "fullscreen", "icon": "view-fullscreen"
if not self.isFullScreen() else "view-restore"},
"separator",
{"text": "Show/hide main window",
"action": "toggle_visibility",
"icon": "view-restore"},
"action": "toggle_visibility", "icon": "view-restore"},
"separator",
{"text": UITexts.CONTEXT_MENU_PROPERTIES, "action": "properties",
"icon": "document-properties"}
{"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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "bagheeraview"
version = "0.9.11"
version = "0.9.12"
authors = [
{ name = "Ignacio Serantes" }
]

View File

@@ -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
)

View File

@@ -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 "

View File

@@ -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):