437 lines
17 KiB
Python
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"
|