From b5b70326b1150af6dd139a446487c202bac7563b Mon Sep 17 00:00:00 2001 From: Ignacio Serantes Date: Sun, 19 Apr 2026 12:18:27 +0200 Subject: [PATCH] v0.9.23 --- bagheeraview.py | 2 +- constants.py | 5 +- imageviewer.py | 2 +- metadatamanager.py | 37 ++++++++++++ propertiesdialog.py | 137 ++++++++++++++++++++++++++++++++++---------- pyproject.toml | 2 +- setup.py | 2 +- 7 files changed, 153 insertions(+), 34 deletions(-) diff --git a/bagheeraview.py b/bagheeraview.py index a6ab94a..b81ee72 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.22" +__version__ = "0.9.23" __author__ = "Ignacio Serantes" __email__ = "kde@aynoa.net" __license__ = "LGPL" diff --git a/constants.py b/constants.py index 8b0c17f..d3a1f14 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.22" +PROG_VERSION = "0.9.23" PROG_AUTHOR = "Ignacio Serantes" # --- CACHE SETTINGS --- @@ -852,6 +852,7 @@ _UI_TEXTS = { "PROPERTIES_TABLE_HEADER": ["Property", "Value"], "PROPERTIES_ADD_ATTR": "Add Attribute", "PROPERTIES_ADD_ATTR_NAME": "Attribute Name (e.g. user.comment):", + "PROPERTIES_DELETE_ALL": "Delete All", "PROPERTIES_ADD_ATTR_VALUE": "Value for {}:", "PROPERTIES_ERROR_SET_ATTR": "Failed to set xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Failed to add xattr: {}", @@ -1403,6 +1404,7 @@ _UI_TEXTS = { "PROPERTIES_TABLE_HEADER": ["Propiedad", "Valor"], "PROPERTIES_ADD_ATTR": "Añadir Atributo", "PROPERTIES_ADD_ATTR_NAME": "Nombre del Atributo (ej. user.comment):", + "PROPERTIES_DELETE_ALL": "Borrar Todo", "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", "PROPERTIES_ERROR_SET_ATTR": "Fallo al establecer xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Fallo al añadir xattr: {}", @@ -1954,6 +1956,7 @@ _UI_TEXTS = { "PROPERTIES_TABLE_HEADER": ["Propiedade", "Valor"], "PROPERTIES_ADD_ATTR": "Engadir Atributo", "PROPERTIES_ADD_ATTR_NAME": "Nome do Atributo (ex. user.comment):", + "PROPERTIES_DELETE_ALL": "Borrar Todo", "PROPERTIES_ADD_ATTR_VALUE": "Valor para {}:", "PROPERTIES_ERROR_SET_ATTR": "Fallo ao establecer xattr: {}", "PROPERTIES_ERROR_ADD_ATTR": "Fallo ao engadir xattr: {}", diff --git a/imageviewer.py b/imageviewer.py index 50de3dd..409bcc6 100644 --- a/imageviewer.py +++ b/imageviewer.py @@ -3331,7 +3331,7 @@ class ImageViewer(QWidget): # Speed 1 (slowest) requires a full 120 delta. # Speed 10 (fastest) requires 120/10 = 12 delta. # Still too fast so speed / 2. - threshold = 120 / speed / 2 + threshold = 120 / speed * 2 self._wheel_scroll_accumulator += event.angleDelta().y() diff --git a/metadatamanager.py b/metadatamanager.py index 4c27265..19eda02 100644 --- a/metadatamanager.py +++ b/metadatamanager.py @@ -121,6 +121,43 @@ class MetadataManager: return all_metadata + @staticmethod + def write_metadata(path, metadata_dict): + """ + Writes EXIF, IPTC, and XMP metadata back to a file. + + Args: + path (str): The path to the image file. + metadata_dict (dict): A dictionary of metadata keys and values. + """ + if not HAVE_EXIV2: + return + + try: + image = exiv2.ImageFactory.open(path) + image.readMetadata() + + exif = image.exifData() + iptc = image.iptcData() + xmp = image.xmpData() + + for key, value in metadata_dict.items(): + try: + if key.startswith("Exif."): + exif[key] = str(value) + elif key.startswith("Iptc."): + iptc[key] = str(value) + elif key.startswith("Xmp."): + xmp[key] = str(value) + except Exception as e: + print(f"Error setting metadata key {key}: {e}") + + image.writeMetadata() + notify_baloo(path) + mark_app_modified(path) + except Exception as e: + print(f"Error writing metadata for {path}: {e}") + class XattrManager: """A manager class to handle reading and writing extended attributes (xattrs).""" diff --git a/propertiesdialog.py b/propertiesdialog.py index cd3d96b..efe80c9 100644 --- a/propertiesdialog.py +++ b/propertiesdialog.py @@ -12,7 +12,7 @@ Classes: from PySide6.QtWidgets import ( QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog, QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget, - QFormLayout, QDialogButtonBox, QApplication + QFormLayout, QDialogButtonBox, QApplication, QToolBar, QAbstractItemView ) from PySide6.QtGui import ( QImageReader, QIcon, QColor @@ -76,6 +76,8 @@ class PropertiesDialog(QDialog): self.setWindowTitle(UITexts.PROPERTIES_TITLE) self._initial_tags = initial_tags if initial_tags is not None else [] self._initial_rating = initial_rating + self.original_xattrs = {} + self.original_exif = {} self.loader = None self.resize(400, 500) self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) @@ -136,6 +138,11 @@ class PropertiesDialog(QDialog): meta_widget = QWidget() meta_layout = QVBoxLayout(meta_widget) + self.meta_toolbar = QToolBar() + self._setup_table_toolbar(self.meta_toolbar, self.on_add_meta, self.on_delete_meta, + self.on_delete_all_meta, self.on_save_meta, self.on_cancel_meta) + meta_layout.addWidget(self.meta_toolbar) + self.table = QTableWidget() self.table.setColumnCount(2) self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER) @@ -145,12 +152,12 @@ class PropertiesDialog(QDialog): self.table.setColumnWidth(0, self.width() * 0.4) self.table.verticalHeader().setVisible(False) self.table.setAlternatingRowColors(True) - self.table.setEditTriggers(QTableWidget.DoubleClicked | - QTableWidget.EditKeyPressed | - QTableWidget.SelectedClicked) + self.table.setEditTriggers(QAbstractItemView.DoubleClicked | + QAbstractItemView.EditKeyPressed | + QAbstractItemView.AnyKeyPressed) self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.table.itemChanged.connect(self.on_item_changed) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.customContextMenuRequested.connect(self.show_context_menu) @@ -164,6 +171,11 @@ class PropertiesDialog(QDialog): exif_widget = QWidget() exif_layout = QVBoxLayout(exif_widget) + self.exif_toolbar = QToolBar() + self._setup_table_toolbar(self.exif_toolbar, self.on_add_exif, self.on_delete_exif, + self.on_delete_all_exif, self.on_save_exif, self.on_cancel_exif) + exif_layout.addWidget(self.exif_toolbar) + self.exif_table = QTableWidget() # This table will display EXIF/XMP/IPTC data. # Reading this data involves opening the file with exiv2, which is a disk read. @@ -180,8 +192,11 @@ class PropertiesDialog(QDialog): 1, QHeaderView.ResizeToContents) self.exif_table.verticalHeader().setVisible(False) self.exif_table.setAlternatingRowColors(True) - self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.exif_table.setEditTriggers(QAbstractItemView.DoubleClicked | + QAbstractItemView.EditKeyPressed | + QAbstractItemView.AnyKeyPressed) self.exif_table.setSelectionBehavior(QTableWidget.SelectRows) + self.exif_table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu) # This is a disk read. self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu) @@ -204,6 +219,87 @@ class PropertiesDialog(QDialog): # Start background loading self.reload_metadata() + def _setup_table_toolbar(self, toolbar, add_slot, del_slot, del_all_slot, save_slot, cancel_slot): + """Helper to populate toolbars with buttons.""" + toolbar.addAction(QIcon.fromTheme("list-add"), UITexts.CREATE, add_slot) + toolbar.addAction(QIcon.fromTheme("list-remove"), UITexts.DELETE, del_slot) + toolbar.addAction(QIcon.fromTheme("edit-clear-all"), UITexts.PROPERTIES_DELETE_ALL, del_all_slot) + toolbar.addSeparator() + toolbar.addAction(QIcon.fromTheme("document-save"), UITexts.SAVE, save_slot) + toolbar.addAction(QIcon.fromTheme("edit-undo"), UITexts.CANCEL, cancel_slot) + + def on_add_meta(self): + key, ok = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME) + if ok and key: + row = self.table.rowCount() + self.table.insertRow(row) + self.table.setItem(row, 0, QTableWidgetItem(key)) + v_item = QTableWidgetItem("") + self.table.setItem(row, 1, v_item) + self.table.setCurrentItem(v_item) + self.table.editItem(v_item) + + def on_delete_meta(self): + rows = sorted(set(index.row() for index in self.table.selectedIndexes()), reverse=True) + for row in rows: + self.table.removeRow(row) + + def on_delete_all_meta(self): + self.table.setRowCount(0) + + def on_save_meta(self): + new_attrs = {} + for r in range(self.table.rowCount()): + k_item, v_item = self.table.item(r, 0), self.table.item(r, 1) + if k_item and v_item: + new_attrs[k_item.text()] = v_item.text() + try: + for k in self.original_xattrs: + if k not in new_attrs: + XattrManager.set_attribute(self.path, k, None) + for k, v in new_attrs.items(): + XattrManager.set_attribute(self.path, k, v) + self.reload_metadata() + except Exception as e: + QMessageBox.warning(self, UITexts.ERROR, f"Error: {e}") + + def on_cancel_meta(self): + self.update_metadata_table(self.original_xattrs) + + def on_add_exif(self): + key, ok = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR, UITexts.PROPERTIES_ADD_ATTR_NAME) + if ok and key: + row = self.exif_table.rowCount() + self.exif_table.insertRow(row) + self.exif_table.setItem(row, 0, QTableWidgetItem(key)) + v_item = QTableWidgetItem("") + self.exif_table.setItem(row, 1, v_item) + self.exif_table.setCurrentItem(v_item) + self.exif_table.editItem(v_item) + + def on_delete_exif(self): + rows = sorted(set(index.row() for index in self.exif_table.selectedIndexes()), reverse=True) + for row in rows: + self.exif_table.removeRow(row) + + def on_delete_all_exif(self): + self.exif_table.setRowCount(0) + + def on_save_exif(self): + new_exif = {} + for r in range(self.exif_table.rowCount()): + k_item, v_item = self.exif_table.item(r, 0), self.exif_table.item(r, 1) + if k_item and v_item: + new_exif[k_item.text()] = v_item.text() + try: + MetadataManager.write_metadata(self.path, new_exif) + self.reload_metadata() + except Exception as e: + QMessageBox.warning(self, UITexts.ERROR, f"Error: {e}") + + def on_cancel_exif(self): + self.update_exif_table(self.original_exif) + def done(self, r): if self.loader and self.loader.isRunning(): self.loader.stop() @@ -232,6 +328,7 @@ class PropertiesDialog(QDialog): # Combine preloaded and newly read xattrs all_xattrs = preloaded_xattrs.copy() if not initial_only and disk_xattrs: + self.original_xattrs = disk_xattrs.copy() # Disk data takes precedence or adds to it all_xattrs.update(disk_xattrs) @@ -242,9 +339,9 @@ class PropertiesDialog(QDialog): for key, val in all_xattrs.items(): # QImageReader.textKeys() is not used here as it's not xattr. k_item = QTableWidgetItem(key) - k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable) v_item = QTableWidgetItem(val) - v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable) self.table.setItem(row, 0, k_item) self.table.setItem(row, 1, v_item) row += 1 @@ -303,6 +400,7 @@ class PropertiesDialog(QDialog): self.exif_table.blockSignals(False) return + self.original_exif = exif_data.copy() self.exif_table.setRowCount(len(exif_data)) error_color = QColor("red") error_text_lower = UITexts.ERROR.lower() @@ -310,9 +408,9 @@ class PropertiesDialog(QDialog): for row, (key, value) in enumerate(sorted(exif_data.items())): k_item = QTableWidgetItem(str(key)) - k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable) v_item = QTableWidgetItem(str(value)) - v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable) key_str_lower = str(key).lower() val_str_lower = str(value).lower() @@ -328,25 +426,6 @@ class PropertiesDialog(QDialog): self.exif_table.blockSignals(False) - def on_item_changed(self, item): - """ - Slot that triggers when an item in the metadata table is changed. - - Args: - item (QTableWidgetItem): The item that was changed. - """ - if item.column() == 1: - key = self.table.item(item.row(), 0).text() - val = item.text() - # Treat empty or whitespace-only values as removal to match previous - # behavior - val_to_set = val if val.strip() else None - try: - XattrManager.set_attribute(self.path, key, val_to_set) - except Exception as e: - QMessageBox.warning(self, UITexts.ERROR, - UITexts.PROPERTIES_ERROR_SET_ATTR.format(e)) - def show_context_menu(self, pos): """ Displays a context menu in the metadata table. diff --git a/pyproject.toml b/pyproject.toml index 2acc9d4..d34b2c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "bagheeraview" -version = "0.9.22" +version = "0.9.23" authors = [ { name = "Ignacio Serantes" } ] diff --git a/setup.py b/setup.py index 2b3cf39..2e70f4b 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="bagheeraview", - version="0.9.22", + version="0.9.23", 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 "