First commit

This commit is contained in:
Ignacio Serantes
2026-03-22 18:16:51 +01:00
commit a402828d1a
23 changed files with 14768 additions and 0 deletions

403
propertiesdialog.py Normal file
View File

@@ -0,0 +1,403 @@
"""
Properties Dialog Module for Bagheera Image Viewer.
This module provides the properties dialog for the application, which displays
detailed information about an image file across several tabs: general file
info, editable metadata (extended attributes), and EXIF/XMP/IPTC data.
Classes:
PropertiesDialog: A QDialog that presents file properties in a tabbed
interface.
"""
import os
from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QMessageBox, QMenu, QInputDialog,
QDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QFormLayout, QDialogButtonBox, QApplication
)
from PySide6.QtGui import (
QImageReader, QIcon, QColor
)
from PySide6.QtCore import (
Qt, QFileInfo, QLocale
)
from constants import (
RATING_XATTR_NAME, XATTR_NAME, UITexts
)
from metadatamanager import MetadataManager, HAVE_EXIV2, notify_baloo
from utils import preserve_mtime
class PropertiesDialog(QDialog):
"""
A dialog window to display detailed properties of an image file.
This dialog features multiple tabs:
- General: Basic file information (size, dates, dimensions). This involves os.stat
and QImageReader.
- Metadata: Editable key-value pairs, primarily for extended attributes (xattrs).
- EXIF: Detailed EXIF, IPTC, and XMP metadata, loaded via the exiv2 library.
"""
def __init__(self, path, initial_tags=None, initial_rating=0, parent=None):
"""
Initializes the PropertiesDialog.
Args:
path (str): The absolute path to the image file.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.path = path
self.setWindowTitle(UITexts.PROPERTIES_TITLE)
self._initial_tags = initial_tags if initial_tags is not None else []
self._initial_rating = initial_rating
self.resize(400, 500)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
layout = QVBoxLayout(self)
tabs = QTabWidget()
layout.addWidget(tabs)
# --- General Tab ---
general_widget = QWidget()
form_layout = QFormLayout(general_widget)
form_layout.setLabelAlignment(Qt.AlignRight)
form_layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop)
form_layout.setContentsMargins(20, 20, 20, 20)
form_layout.setSpacing(10)
info = QFileInfo(path)
reader = QImageReader(path)
reader.setAutoTransform(True)
# Basic info
form_layout.addRow(UITexts.PROPERTIES_FILENAME, QLabel(info.fileName()))
form_layout.addRow(UITexts.PROPERTIES_LOCATION, QLabel(info.path()))
form_layout.addRow(UITexts.PROPERTIES_SIZE,
QLabel(self.format_size(info.size())))
# Dates
form_layout.addRow(UITexts.PROPERTIES_CREATED,
QLabel(QLocale.system().toString(info.birthTime(),
QLocale.ShortFormat)))
form_layout.addRow(UITexts.PROPERTIES_MODIFIED,
QLabel(QLocale.system().toString(info.lastModified(),
QLocale.ShortFormat)))
# Image info
size = reader.size()
fmt = reader.format().data().decode('utf-8').upper()
if size.isValid():
form_layout.addRow(UITexts.PROPERTIES_DIMENSIONS,
QLabel(f"{size.width()} x {size.height()} px"))
megapixels = (size.width() * size.height()) / 1_000_000
form_layout.addRow(UITexts.PROPERTIES_MEGAPIXELS,
QLabel(f"{megapixels:.2f} MP"))
# Read image to get depth
img = reader.read()
if not img.isNull():
form_layout.addRow(UITexts.PROPERTIES_COLOR_DEPTH,
QLabel(f"{img.depth()} {UITexts.BITS}"))
if fmt:
form_layout.addRow(UITexts.PROPERTIES_FORMAT, QLabel(fmt))
tabs.addTab(general_widget, QIcon.fromTheme("dialog-information"),
UITexts.PROPERTIES_GENERAL_TAB)
# --- Metadata Tab ---
meta_widget = QWidget()
meta_layout = QVBoxLayout(meta_widget)
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive)
self.table.horizontalHeader().setSectionResizeMode(1,
QHeaderView.ResizeToContents)
self.table.setColumnWidth(0, self.width() * 0.4)
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setEditTriggers(QTableWidget.DoubleClicked |
QTableWidget.EditKeyPressed |
QTableWidget.SelectedClicked)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.itemChanged.connect(self.on_item_changed)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu)
self.load_metadata()
meta_layout.addWidget(self.table)
tabs.addTab(meta_widget, QIcon.fromTheme("document-properties"),
UITexts.PROPERTIES_METADATA_TAB)
# --- EXIF Tab ---
exif_widget = QWidget()
exif_layout = QVBoxLayout(exif_widget)
self.exif_table = QTableWidget()
# This table will display EXIF/XMP/IPTC data.
# Reading this data involves opening the file with exiv2, which is a disk read.
# This is generally acceptable for a properties dialog, as it's an explicit
# user request for detailed information. Caching all possible EXIF data
# for every image might be too memory intensive if not frequently accessed.
# Therefore, this disk read is considered necessary and not easily optimizable
# without a significant architectural change (e.g., a dedicated metadata DB).
self.exif_table.setColumnCount(2)
self.exif_table.setHorizontalHeaderLabels(UITexts.PROPERTIES_TABLE_HEADER)
self.exif_table.horizontalHeader().setSectionResizeMode(
0, QHeaderView.ResizeToContents)
self.exif_table.horizontalHeader().setSectionResizeMode(
1, QHeaderView.ResizeToContents)
self.exif_table.verticalHeader().setVisible(False)
self.exif_table.setAlternatingRowColors(True)
self.exif_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.exif_table.setSelectionBehavior(QTableWidget.SelectRows)
self.exif_table.setContextMenuPolicy(Qt.CustomContextMenu)
# This is a disk read.
self.exif_table.customContextMenuRequested.connect(self.show_exif_context_menu)
self.load_exif_data()
exif_layout.addWidget(self.exif_table)
tabs.addTab(exif_widget, QIcon.fromTheme("view-details"),
UITexts.PROPERTIES_EXIF_TAB)
# Buttons
btn_box = QDialogButtonBox(QDialogButtonBox.Close)
close_button = btn_box.button(QDialogButtonBox.Close)
if close_button:
close_button.setIcon(QIcon.fromTheme("window-close"))
btn_box.rejected.connect(self.close)
layout.addWidget(btn_box)
def load_metadata(self):
"""
Loads metadata from the file's text keys (via QImageReader) and
extended attributes (xattrs) into the metadata table.
"""
self.table.blockSignals(True)
self.table.setRowCount(0)
# Use pre-loaded tags and rating if available
preloaded_xattrs = {}
if self._initial_tags:
preloaded_xattrs[XATTR_NAME] = ", ".join(self._initial_tags)
if self._initial_rating > 0:
preloaded_xattrs[RATING_XATTR_NAME] = str(self._initial_rating)
# Read other xattrs from disk
xattrs = {}
try:
for xkey in os.listxattr(self.path):
# Avoid re-reading already known attributes
if xkey not in preloaded_xattrs:
try:
val = os.getxattr(self.path, xkey) # This is a disk read
try:
val_str = val.decode('utf-8')
except UnicodeDecodeError:
val_str = str(val)
xattrs[xkey] = val_str
except Exception:
pass
except Exception:
pass
# Combine preloaded and newly read xattrs
all_xattrs = {**preloaded_xattrs, **xattrs}
self.table.setRowCount(len(all_xattrs))
row = 0
# Display all xattrs
for key, val in all_xattrs.items():
# QImageReader.textKeys() is not used here as it's not xattr.
k_item = QTableWidgetItem(key)
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
v_item = QTableWidgetItem(val)
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.table.setItem(row, 0, k_item)
self.table.setItem(row, 1, v_item)
row += 1
self.table.blockSignals(False)
def load_exif_data(self):
"""Loads EXIF, XMP, and IPTC metadata using the MetadataManager."""
self.exif_table.blockSignals(True)
self.exif_table.setRowCount(0)
if not HAVE_EXIV2:
self.exif_table.setRowCount(1)
error_color = QColor("red")
item = QTableWidgetItem(UITexts.ERROR)
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
item.setForeground(error_color)
self.exif_table.setItem(0, 0, item)
msg_item = QTableWidgetItem(UITexts.EXIV2_NOT_INSTALLED)
msg_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
msg_item.setForeground(error_color)
self.exif_table.setItem(0, 1, msg_item)
self.exif_table.blockSignals(False)
return
exif_data = MetadataManager.read_all_metadata(self.path)
if not exif_data:
self.exif_table.setRowCount(1)
item = QTableWidgetItem(UITexts.INFO)
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.exif_table.setItem(0, 0, item)
msg_item = QTableWidgetItem(UITexts.NO_METADATA_FOUND)
msg_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.exif_table.setItem(0, 1, msg_item)
self.exif_table.blockSignals(False)
return
self.exif_table.setRowCount(len(exif_data))
error_color = QColor("red")
error_text_lower = UITexts.ERROR.lower()
warning_text_lower = UITexts.WARNING.lower()
for row, (key, value) in enumerate(sorted(exif_data.items())):
k_item = QTableWidgetItem(str(key))
k_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
v_item = QTableWidgetItem(str(value))
v_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
key_str_lower = str(key).lower()
val_str_lower = str(value).lower()
if (error_text_lower in key_str_lower or warning_text_lower
in key_str_lower or
error_text_lower in val_str_lower
or warning_text_lower in val_str_lower):
k_item.setForeground(error_color)
v_item.setForeground(error_color)
self.exif_table.setItem(row, 0, k_item)
self.exif_table.setItem(row, 1, v_item)
self.exif_table.blockSignals(False)
def on_item_changed(self, item):
"""
Slot that triggers when an item in the metadata table is changed.
Args:
item (QTableWidgetItem): The item that was changed.
"""
if item.column() == 1:
key = self.table.item(item.row(), 0).text()
val = item.text()
try:
with preserve_mtime(self.path):
if not val.strip():
try:
os.removexattr(self.path, key)
except OSError:
pass
else:
os.setxattr(self.path, key, val.encode('utf-8'))
notify_baloo(self.path)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_SET_ATTR.format(e))
def show_context_menu(self, pos):
"""
Displays a context menu in the metadata table.
Args:
pos (QPoint): The position where the context menu was requested.
"""
menu = QMenu()
add_action = menu.addAction(QIcon.fromTheme("list-add"),
UITexts.PROPERTIES_ADD_ATTR)
item = self.table.itemAt(pos)
copy_action = None
delete_action = None
if item:
copy_action = menu.addAction(QIcon.fromTheme("edit-copy"),
UITexts.COPY)
val_item = self.table.item(item.row(), 1)
if val_item.flags() & Qt.ItemIsEditable:
delete_action = menu.addAction(QIcon.fromTheme("list-remove"),
UITexts.PROPERTIES_DELETE_ATTR)
action = menu.exec(self.table.mapToGlobal(pos))
if action == add_action:
self.add_attribute()
elif copy_action and action == copy_action:
val = self.table.item(item.row(), 1).text()
QApplication.clipboard().setText(val)
elif delete_action and action == delete_action:
self.delete_attribute(item.row())
def show_exif_context_menu(self, pos):
"""Displays a context menu in the EXIF table (Copy only)."""
menu = QMenu()
item = self.exif_table.itemAt(pos)
if item:
copy_action = menu.addAction(QIcon.fromTheme("edit-copy"), UITexts.COPY)
action = menu.exec(self.exif_table.mapToGlobal(pos))
if action == copy_action:
val = self.exif_table.item(item.row(), 1).text()
QApplication.clipboard().setText(val)
def add_attribute(self):
"""
Opens dialogs to get a key and value for a new extended attribute and applies
it.
"""
key, ok = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR,
UITexts.PROPERTIES_ADD_ATTR_NAME)
if ok and key:
val, ok2 = QInputDialog.getText(self, UITexts.PROPERTIES_ADD_ATTR,
UITexts.PROPERTIES_ADD_ATTR_VALUE.format(
key))
if ok2:
try:
with preserve_mtime(self.path):
os.setxattr(self.path, key, val.encode('utf-8'))
notify_baloo(self.path)
self.load_metadata()
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_ADD_ATTR.format(e))
def delete_attribute(self, row):
"""
Deletes the extended attribute corresponding to the given table row.
Args:
row (int): The row index of the attribute to delete.
"""
key = self.table.item(row, 0).text()
try:
with preserve_mtime(self.path):
os.removexattr(self.path, key)
notify_baloo(self.path)
self.table.removeRow(row)
except Exception as e:
QMessageBox.warning(self, UITexts.ERROR,
UITexts.PROPERTIES_ERROR_DELETE_ATTR.format(e))
def format_size(self, size):
"""
Formats a size in bytes into a human-readable string (B, KiB, MiB, etc.).
Args:
size (int): The size in bytes.
Returns:
str: The formatted size string.
"""
for unit in ['B', 'KiB', 'MiB', 'GiB']:
if size < 1024:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} TiB"