First commit
This commit is contained in:
403
propertiesdialog.py
Normal file
403
propertiesdialog.py
Normal 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"
|
||||
Reference in New Issue
Block a user