Files
BagheeraView/propertiesdialog.py
Ignacio Serantes 291f2f9e47 A bunch of changes
2026-03-23 22:50:02 +01:00

437 lines
17 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.
"""
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 (QThread, Signal, Qt, QFileInfo, QLocale)
from constants import (
RATING_XATTR_NAME, XATTR_NAME, UITexts
)
from metadatamanager import MetadataManager, HAVE_EXIV2, XattrManager
class PropertiesLoader(QThread):
"""Background thread to load metadata (xattrs and EXIF) asynchronously."""
loaded = Signal(dict, dict)
def __init__(self, path, parent=None):
super().__init__(parent)
self.path = path
self._abort = False
def stop(self):
"""Signals the thread to stop and waits for it."""
self._abort = True
self.wait()
def run(self):
# Xattrs
if self._abort:
return
xattrs = XattrManager.get_all_attributes(self.path)
if self._abort:
return
# EXIF
exif_data = MetadataManager.read_all_metadata(self.path)
if not self._abort:
self.loaded.emit(xattrs, exif_data)
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.loader = None
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)
# Initial partial load (synchronous, just passed args)
self.update_metadata_table({}, initial_only=True)
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)
# Placeholder for EXIF
self.update_exif_table(None)
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)
# Start background loading
self.reload_metadata()
def closeEvent(self, event):
if self.loader and self.loader.isRunning():
self.loader.stop()
super().closeEvent(event)
def update_metadata_table(self, disk_xattrs, initial_only=False):
"""
Updates the metadata table with extended attributes.
Merges initial tags/rating with loaded xattrs.
"""
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)
# Combine preloaded and newly read xattrs
all_xattrs = preloaded_xattrs.copy()
if not initial_only and disk_xattrs:
# Disk data takes precedence or adds to it
all_xattrs.update(disk_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 reload_metadata(self):
"""Starts the background thread to load metadata."""
if self.loader and self.loader.isRunning():
# Already running
return
self.loader = PropertiesLoader(self.path, self)
self.loader.loaded.connect(self.on_data_loaded)
self.loader.start()
def on_data_loaded(self, xattrs, exif_data):
"""Slot called when metadata is loaded from the thread."""
self.update_metadata_table(xattrs, initial_only=False)
self.update_exif_table(exif_data)
def update_exif_table(self, exif_data):
"""Updates the EXIF table with loaded data."""
self.exif_table.blockSignals(True)
self.exif_table.setRowCount(0)
if exif_data is None:
# Loading state
self.exif_table.setRowCount(1)
item = QTableWidgetItem("Loading data...")
item.setFlags(Qt.ItemIsEnabled)
self.exif_table.setItem(0, 0, item)
self.exif_table.blockSignals(False)
return
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
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()
# 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.
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:
XattrManager.set_attribute(self.path, key, val)
self.reload_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:
XattrManager.set_attribute(self.path, key, None)
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"