Files
BagheeraView/propertiesdialog.py
Ignacio Serantes a402828d1a First commit
2026-03-22 18:16:51 +01:00

404 lines
16 KiB
Python

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