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

1403 lines
56 KiB
Python

"""
Custom Widgets for the Bagheera Image Viewer.
This module provides specialized Qt widgets used throughout the Bagheera UI,
including:
- TagTreeView: A tree view with custom click handling for tag management.
- TagEditWidget: A comprehensive widget for viewing and editing file tags,
integrating with Baloo for available tags.
- LayoutsWidget: A widget to manage, save, and load window layouts.
- HistoryWidget: A widget to display and manage search/view history.
"""
import os
import glob
import shutil
import lmdb
from datetime import datetime
from collections import deque
from PySide6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton,
QMessageBox, QSizePolicy, QInputDialog, QTableWidget, QTableWidgetItem,
QMenu, QHeaderView, QAbstractItemView, QTreeView, QLabel, QTextEdit,
QComboBox, QCompleter, QToolBar
)
from PySide6.QtGui import (
QIcon, QStandardItemModel, QStandardItem, QColor, QPainter, QPen,
QPalette, QAction,
)
from PySide6.QtCore import (
Signal, QSortFilterProxyModel, Slot, QStringListModel, Qt
)
from metadatamanager import XattrManager
from constants import (
LAYOUTS_DIR, RATING_XATTR_NAME, XATTR_COMMENT_NAME, XATTR_NAME, UITexts,
FACES_MENU_MAX_ITEMS_DEFAULT, APP_CONFIG
)
class TagTreeView(QTreeView):
"""Custom TreeView supporting Ctrl+Click to force-mark changes.
This class extends QTreeView to implement a special handling for Ctrl+Click
events on checkable items, allowing users to forcefully toggle their state.
"""
search_requested = Signal(object)
add_and_requested = Signal(object)
add_or_requested = Signal(object)
def mousePressEvent(self, event):
"""Handles mouse press events to implement Ctrl+Click toggling.
If Ctrl is held down while clicking a checkable item, its check state
is toggled directly, bypassing the default model behavior. This is used
to "force" a change state on a tag.
Args:
event (QMouseEvent): The mouse press event.
"""
index = self.indexAt(event.position().toPoint())
if index.isValid() and event.modifiers() == Qt.ControlModifier:
# When Ctrl is pressed, we manually toggle the check state
# of the item. This allows forcing a "changed" state even if
# the tag is already applied to all/no files.
model = self.model()
source_index = (model.mapToSource(index)
if isinstance(model, QSortFilterProxyModel)
else index)
item = model.sourceModel().itemFromIndex(source_index)
if item and item.isCheckable():
# Toggle check state manually
new_state = (Qt.Unchecked if item.checkState() == Qt.Checked
else Qt.Checked)
item.setCheckState(new_state)
return
super().mousePressEvent(event)
def contextMenuEvent(self, event):
"""Shows a context menu to trigger a search for the selected tag."""
index = self.indexAt(event.pos())
if index.isValid():
model = self.model()
source_index = (model.mapToSource(index)
if isinstance(model, QSortFilterProxyModel)
else index)
item = model.sourceModel().itemFromIndex(source_index)
# Don't show menu for the root items "USED TAGS", "ALL TAGS"
if item and item.parent():
menu = QMenu(self)
search_action = menu.addAction(QIcon.fromTheme("system-search"),
UITexts.SEARCH_BY_TAG)
add_and_action = menu.addAction(UITexts.SEARCH_ADD_AND)
add_or_action = menu.addAction(UITexts.SEARCH_ADD_OR)
action = menu.exec(event.globalPos())
if action == search_action:
self.search_requested.emit(index)
elif action == add_and_action:
self.add_and_requested.emit(index)
elif action == add_or_action:
self.add_or_requested.emit(index)
super().contextMenuEvent(event)
class TagEditWidget(QWidget):
"""A widget for editing tags associated with one or more files."""
tags_updated = Signal(dict)
def __init__(self, main_win=None, parent=None):
"""Initializes the tag editing widget and its UI components."""
super().__init__(parent)
self.main_win = main_win
self.file_paths = [] # Paths of the files being edited
self.initial_states = {}
self.original_tags_per_file = {}
self.manually_changed = set()
self.forced_sync_tags = set()
self.item_mapping = {}
self.available_tags = []
self._is_updating = False
self._load_all = True
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Search bar and add button
search_layout = QHBoxLayout()
self.search_bar = QLineEdit()
self.search_bar.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER)
self.search_bar.setClearButtonEnabled(True)
self.btn_add_tag = QPushButton("+")
self.btn_add_tag.setFixedWidth(30)
search_layout.addWidget(self.search_bar)
search_layout.addWidget(self.btn_add_tag)
layout.addLayout(search_layout)
# Tag tree view setup
self.source_model = QStandardItemModel()
self.proxy_model = QSortFilterProxyModel()
self.proxy_model.setSourceModel(self.source_model)
self.proxy_model.setRecursiveFilteringEnabled(True)
self.tree_view = TagTreeView()
self.tree_view.setModel(self.proxy_model)
self.tree_view.setHeaderHidden(True)
self.tree_view.setExpandsOnDoubleClick(False)
self.tree_view.setEditTriggers(QTreeView.NoEditTriggers)
layout.addWidget(self.tree_view)
# Apply button
self.btn_apply = QPushButton(UITexts.TAG_APPLY_CHANGES)
layout.addWidget(self.btn_apply)
self.load_available_tags()
self._load_all = True
# Connect signals to slots
self.btn_apply.clicked.connect(self.save_changes)
self.btn_add_tag.clicked.connect(self.create_new_tag)
self.search_bar.textChanged.connect(self.handle_search)
self.source_model.itemChanged.connect(self.sync_tags)
self.tree_view.search_requested.connect(self.on_search_requested)
self.tree_view.add_and_requested.connect(self.on_add_and_requested)
self.tree_view.add_or_requested.connect(self.on_add_or_requested)
def set_files_data(self, files_data):
"""Sets the files whose tags are to be edited.
Args:
files_data (dict): A dictionary mapping file paths to a list of
their current tags.
"""
self.file_paths = list(files_data.keys())
self.original_tags_per_file = {path: set(tags) for path,
tags in files_data.items()}
self.refresh_ui()
def load_available_tags(self):
"""Loads all known tags from the Baloo index database."""
db_path = os.path.expanduser("~/.local/share/baloo/index")
if not os.path.exists(db_path):
self.available_tags = []
return
tags = []
try:
# Connect to the LMDB environment for Baloo
with lmdb.Environment(db_path, subdir=False, readonly=True,
lock=False, max_dbs=20) as env:
postingdb = env.open_db(b'postingdb')
with env.begin() as txn:
cursor = txn.cursor(postingdb)
prefix = b'TAG-'
# Iterate over keys starting with the tag prefix
if cursor.set_range(prefix):
for key, _ in cursor:
if not key.startswith(prefix):
break
tags.append(key[4:].decode('utf-8'))
except Exception:
# Silently fail if Baloo DB is not accessible
pass
self.available_tags = tags
def init_data(self):
"""Initializes or updates the tag tree model based on current files."""
self._is_updating = True
try:
if self._load_all:
# First time loading: build the full tree structure
self.source_model.clear()
self.item_mapping = {}
self.root_favs = QStandardItem(UITexts.TAG_USED_TAGS)
self.root_all = QStandardItem(UITexts.TAG_ALL_TAGS)
self.root_favs.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
self.root_all.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
# self.source_model.insertRow(self.root_favs, 0)
self.source_model.appendRow(self.root_favs)
self.source_model.appendRow(self.root_all)
tag_counts = {}
for path in self.file_paths:
tags = self.original_tags_per_file.get(path, set())
for t in tags:
tag_counts[t] = tag_counts.get(t, 0) + 1
# Combine tags from files and all available tags from Baloo
master = sorted(list(set(self.available_tags) | set(tag_counts.keys())))
total = len(self.file_paths) if self.file_paths else 1
for t_path in master:
count = tag_counts.get(t_path, 0)
is_checked = count > 0
# Italicize if the tag is applied to some but not all files
is_italic = (0 < count < total and len(self.file_paths) > 1)
self.initial_states[t_path] = is_checked
self.get_or_create_node(t_path, self.root_all, is_checked,
is_italic)
if is_checked:
self.get_or_create_node(t_path, self.root_favs, True,
is_italic)
self._load_all = False
else:
# Subsequent loads: update existing tree
tag_counts = {}
for path in self.file_paths:
tags = self.original_tags_per_file.get(path, set())
for t in tags:
tag_counts[t] = tag_counts.get(t, 0) + 1
total = len(self.file_paths) if self.file_paths else 1
if self.root_favs.hasChildren():
self.root_favs.removeRows(0, self.root_favs.rowCount())
# Clear references to deleted items in the 'Used Tags' section
for key in self.item_mapping:
self.item_mapping[key][1] = None
# Optimization: Reset known nodes via map instead of recursive traversal
for t_path, nodes in self.item_mapping.items():
self.initial_states[t_path] = False
node_all = nodes[0]
if node_all:
if node_all.checkState() != Qt.Unchecked:
node_all.setCheckState(Qt.Unchecked)
font = node_all.font()
if font.italic():
font.setItalic(False)
node_all.setFont(font)
if node_all.foreground().color().name() != "#ffffff":
node_all.setForeground(QColor("#ffffff"))
# Iterate only active tags to check/italicize
for t_path, count in tag_counts.items():
if count > 0:
is_italic = (0 < count < total and len(self.file_paths) > 1)
self.initial_states[t_path] = True
self.get_or_create_node(t_path, self.root_favs, True, is_italic)
self.get_or_create_node(t_path, self.root_all, True, is_italic)
self.reset_expansion()
finally:
self._is_updating = False
def get_or_create_node(self, full_path, root, checked, italic):
"""Finds or creates a hierarchical node in the tree for a given tag path.
Args:
full_path (str): The full hierarchical tag (e.g., "Photos/Family").
root (QStandardItem): The root item to build under (e.g., "All Tags").
checked (bool): The initial check state of the final node.
italic (bool): Whether the node font should be italic.
"""
parts, curr = full_path.split('/'), root
for i, part in enumerate(parts):
c_path = "/".join(parts[:i+1])
found = None
# Find if child already exists
for row in range(curr.rowCount()):
if curr.child(row, 0).text() == part:
found = curr.child(row, 0)
break
if not found:
# Create new node if it doesn't exist
node = QStandardItem(part)
if c_path == full_path:
# This is the final node in the path, make it checkable
node.setCheckable(True)
node.setCheckState(Qt.Checked if checked else Qt.Unchecked)
self._style_node(node, full_path, checked, italic)
if full_path not in self.item_mapping:
self.item_mapping[full_path] = [None, None]
# Store reference to the node under 'all' or 'used' root
self.item_mapping[full_path][0 if root == self.root_all else 1] = \
node
curr.appendRow(node)
curr = node
else:
# Node already exists, update it
curr = found
if c_path == full_path:
curr.setCheckState(Qt.Checked if checked else Qt.Unchecked)
self._style_node(curr, full_path, checked, italic)
def _style_node(self, node, path, current_checked, italic):
"""Applies visual styling (font, color) to a tag node."""
font = node.font()
# Use italic for partially applied tags, unless forced
font.setItalic(italic if path not in self.forced_sync_tags else False)
node.setFont(font)
# Highlight manually changed tags
color = "#569cd6" if path in self.manually_changed else "#ffffff"
node.setForeground(QColor(color))
def reconstruct_path(self, item):
"""Builds the full hierarchical tag path from a model item."""
p, c = [], item
while c and c not in [self.root_all, self.root_favs]:
p.insert(0, c.text())
c = c.parent()
return "/".join(p) if p else None
def sync_tags(self, item):
"""Synchronizes the state of a tag between the 'Used' and 'All' trees.
Triggered when a tag's check state changes. It also tracks manual
changes to highlight them and prepare for saving.
"""
if self._is_updating:
return
if not item:
return
path = self.reconstruct_path(item)
if not path or path not in self.item_mapping:
return
new_state = (item.checkState() == Qt.Checked)
if new_state: # and self.item_mapping[path][1] is None:
# If a tag is checked, ensure it appears in the "Used Tags" list
self.get_or_create_node(path, self.root_favs, True, item.font().italic())
self.reset_expansion()
if QApplication.keyboardModifiers() == Qt.ControlModifier:
# Ctrl+Click forces a tag to be considered "changed"
self.forced_sync_tags.add(path)
# Track if the state differs from the initial state
if (new_state != self.initial_states.get(path, False)
or path in self.forced_sync_tags):
self.manually_changed.add(path)
else:
self.manually_changed.discard(path)
# Update the corresponding node in the other tree to match
self._is_updating = True
try:
for node in self.item_mapping[path]:
if node:
try:
node.setCheckState(item.checkState())
self._style_node(node, path, new_state, node.font().italic())
except RuntimeError:
pass
finally:
self._is_updating = False
def _get_tag_search_string(self, proxy_index):
"""Generates the search string for the tag at the given index."""
source_index = self.proxy_model.mapToSource(proxy_index)
item = self.source_model.itemFromIndex(source_index)
if not item:
return ""
full_path = self.reconstruct_path(item)
if not full_path:
return ""
words = full_path.replace('/', ' ').split()
search_terms = [f"tags:{word}" for word in words if word]
return " ".join(search_terms)
def _get_current_query_text(self):
"""Extracts the effective query text from the main window search input."""
if not self.main_win:
return ""
text = self.main_win.search_input.currentText().strip()
if text.startswith("search:/"):
return text[8:]
if text.startswith("file:/") or text.startswith("/") or os.path.exists(text):
return ""
return text
@Slot(object)
def on_search_requested(self, proxy_index):
"""Handles the request to search for a tag from the context menu."""
search_string = self._get_tag_search_string(proxy_index)
if search_string:
self.main_win.process_term(f"search:/{search_string}")
@Slot(object)
def on_add_and_requested(self, proxy_index):
"""Handles request to add a tag with AND to the current search."""
if not self.main_win:
return
new_term = self._get_tag_search_string(proxy_index)
if not new_term:
return
current_query = self._get_current_query_text()
if current_query:
final_query = f"({current_query}) AND ({new_term})"
else:
final_query = new_term
self.main_win.process_term(f"search:/{final_query}")
@Slot(object)
def on_add_or_requested(self, proxy_index):
"""Handles request to add a tag with OR to the current search."""
if not self.main_win:
return
new_term = self._get_tag_search_string(proxy_index)
if not new_term:
return
current_query = self._get_current_query_text()
if current_query:
final_query = f"({current_query}) OR ({new_term})"
else:
final_query = new_term
self.main_win.process_term(f"search:/{final_query}")
def save_changes(self):
"""Applies the tracked tag changes to the selected files' xattrs."""
QApplication.setOverrideCursor(Qt.WaitCursor)
paths_to_index = []
all_newly_added_tags = set()
updated_files_tags = {}
try:
for path in self.file_paths:
try:
file_tags = self.original_tags_per_file.get(path, set()).copy()
original_file_tags = set(file_tags)
for t in self.manually_changed:
nodes = self.item_mapping.get(t)
if not nodes:
continue
node = nodes[0] or nodes[1]
if node.checkState() == Qt.Checked:
file_tags.add(t)
else:
file_tags.discard(t)
# Filter out any empty or whitespace-only tags before saving.
# The use of a set already handles duplicates.
final_tags = {tag.strip() for tag in file_tags if tag.strip()}
newly_added_tags = final_tags - original_file_tags
all_newly_added_tags.update(newly_added_tags)
tags_str = ",".join(sorted(list(final_tags))) if final_tags \
else None
XattrManager.set_attribute(path, XATTR_NAME, tags_str)
self.original_tags_per_file[path] = final_tags
updated_files_tags[path] = sorted(list(final_tags))
paths_to_index.append(path)
except Exception:
continue
if self.main_win:
for tag in sorted(list(all_newly_added_tags)):
self.main_win.add_to_mru_tags(tag)
# Refresh status bar on any open viewer showing one of the modified files
if self.main_win:
for viewer in self.main_win.viewers:
if viewer.controller.get_current_path() in paths_to_index:
viewer.update_status_bar()
self.load_available_tags()
self._load_all = False
self.refresh_ui()
self.tags_updated.emit(updated_files_tags)
except Exception as e:
QMessageBox.critical(self, "Error", str(e))
finally:
QApplication.restoreOverrideCursor()
def create_new_tag(self):
"""Opens a dialog to create a new tag and adds it to the trees."""
new_tag, ok = QInputDialog.getText(self, UITexts.TAG_NEW_TAG_TITLE,
UITexts.TAG_NEW_TAG_TEXT)
if ok and new_tag.strip():
tag_path = new_tag.strip()
# Mark it as a forced, manual change to ensure it gets saved
self.forced_sync_tags.add(tag_path)
self.manually_changed.add(tag_path)
# Add the new tag to both trees, checked by default
self.get_or_create_node(tag_path, self.root_all, True, False)
self.get_or_create_node(tag_path, self.root_favs, True, False)
self.reset_expansion()
def handle_search(self, text):
"""Filters the tag tree based on the search bar text."""
self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
self.proxy_model.setFilterFixedString(text)
if text:
self.tree_view.expandAll()
else:
self.reset_expansion(True)
def reset_expansion(self, handling_search=False):
"""Resets the tree expansion to a default state."""
if handling_search:
self.tree_view.collapseAll()
fav_idx = self.proxy_model.index(0, 0)
if fav_idx.isValid():
self._expand_recursive(fav_idx)
all_idx = self.proxy_model.index(1, 0)
if all_idx.isValid():
# Expand only the top level of the "All Tags" section
self.tree_view.expand(all_idx)
def _expand_recursive(self, proxy_idx):
"""Recursively expands an item and all its children."""
self.tree_view.expand(proxy_idx)
for i in range(self.proxy_model.rowCount(proxy_idx)):
child = self.proxy_model.index(i, 0, proxy_idx)
if child.isValid():
self._expand_recursive(child)
def refresh_ui(self):
"""Resets the widget's state and re-initializes the data."""
self.initial_states = {}
self.manually_changed = set()
self.forced_sync_tags = set()
self.init_data()
class LayoutsWidget(QWidget):
"""A widget for managing saved window and viewer layouts."""
def __init__(self, main_win):
"""Initializes the layouts widget and its UI.
Args:
main_win (MainWindow): Reference to the main application window.
"""
super().__init__()
self.main_win = main_win
layout = QVBoxLayout(self)
# Table to display saved layouts
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.LAYOUTS_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.verticalHeader().setVisible(False)
self.table.setSortingEnabled(True)
self.table.doubleClicked.connect(self.load_selected)
layout.addWidget(self.table)
toolbar = QToolBar()
layout.addWidget(toolbar)
load_action = QAction(QIcon.fromTheme("document-open"), UITexts.LOAD, self)
load_action.triggered.connect(self.load_selected)
toolbar.addAction(load_action)
create_action = QAction(QIcon.fromTheme("document-new"), UITexts.CREATE, self)
create_action.triggered.connect(self.create_layout)
toolbar.addAction(create_action)
save_action = QAction(QIcon.fromTheme("document-save"), UITexts.SAVE, self)
save_action.triggered.connect(self.save_selected_layout)
toolbar.addAction(save_action)
rename_action = QAction(QIcon.fromTheme("edit-rename"), UITexts.RENAME, self)
rename_action.triggered.connect(self.rename_layout)
toolbar.addAction(rename_action)
copy_action = QAction(QIcon.fromTheme("edit-copy"), UITexts.COPY, self)
copy_action.triggered.connect(self.copy_layout)
toolbar.addAction(copy_action)
delete_action = QAction(QIcon.fromTheme("edit-delete"), UITexts.DELETE, self)
delete_action.triggered.connect(self.delete_layout)
toolbar.addAction(delete_action)
self.refresh_list()
def resizeEvent(self, event):
"""Adjusts column widths on resize."""
width = self.table.viewport().width()
self.table.setColumnWidth(0, int(width * 0.80))
super().resizeEvent(event)
def refresh_list(self):
"""Reloads the list of saved layouts from the layouts directory."""
self.table.setSortingEnabled(False)
self.table.setRowCount(0)
if not os.path.exists(LAYOUTS_DIR):
return
# Find all .layout files
files = glob.glob(os.path.join(LAYOUTS_DIR, "*.layout"))
files.sort(key=os.path.getmtime, reverse=True)
self.table.setRowCount(len(files))
for i, f_path in enumerate(files):
name = os.path.basename(f_path).replace(".layout", "")
mtime = os.path.getmtime(f_path)
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
item_name = QTableWidgetItem(name)
item_name.setData(Qt.UserRole, f_path)
item_name.setData(Qt.UserRole, f_path) # Store full path in item
item_date = QTableWidgetItem(dt)
self.table.setItem(i, 0, item_name)
self.table.setItem(i, 1, item_date)
self.table.setSortingEnabled(True)
def get_selected_path(self):
"""Gets the file path of the currently selected layout in the table.
Returns:
str or None: The full path to the selected .layout file, or None.
"""
row = self.table.currentRow()
if row >= 0:
return self.table.item(row, 0).data(Qt.UserRole)
return None
def load_selected(self):
"""Loads the currently selected layout."""
path = self.get_selected_path()
if path:
self.main_win.restore_layout(path)
def create_layout(self):
"""Saves the current session as a new layout."""
self.main_win.save_layout()
self.refresh_list()
def save_selected_layout(self):
"""Overwrites the selected layout with the current session state."""
path = self.get_selected_path()
if path:
self.main_win.save_layout(target_path=path)
else:
# If nothing is selected, treat it as a "create new" action
self.create_layout()
def delete_layout(self):
"""Deletes the selected layout file after confirmation."""
path = self.get_selected_path()
if path:
if QMessageBox.question(self,
UITexts.CONFIRM_DELETE_LAYOUT_TITLE,
UITexts.CONFIRM_DELETE_LAYOUT_TEXT.format(
os.path.basename(path)), QMessageBox.Yes
| QMessageBox.No) == QMessageBox.Yes:
os.remove(path)
self.refresh_list()
def rename_layout(self):
"""Renames the selected layout file."""
path = self.get_selected_path()
if not path:
return
old_name = os.path.basename(path).replace(".layout", "")
new_name, ok = QInputDialog.getText(self,
UITexts.RENAME_LAYOUT_TITLE,
UITexts.RENAME_LAYOUT_TEXT,
text=old_name)
if ok and new_name:
new_path = os.path.join(os.path.dirname(path), new_name + ".layout")
if not os.path.exists(new_path):
os.rename(path, new_path)
self.refresh_list()
else:
QMessageBox.warning(self, UITexts.ERROR, UITexts.LAYOUT_ALREADY_EXISTS)
def copy_layout(self):
"""Creates a copy of the selected layout with a new name."""
path = self.get_selected_path()
if not path:
return
old_name = os.path.basename(path).replace(".layout", "")
new_name, ok = QInputDialog.getText(self,
UITexts.COPY_LAYOUT_TITLE,
UITexts.COPY_LAYOUT_TEXT,
text=old_name + "_copy")
if ok and new_name:
new_path = os.path.join(os.path.dirname(path), new_name + ".layout")
if not os.path.exists(new_path):
shutil.copy(path, new_path)
self.refresh_list()
else:
QMessageBox.warning(self, UITexts.ERROR, UITexts.LAYOUT_ALREADY_EXISTS)
class HistoryWidget(QWidget):
"""A widget to display and manage the application's browsing history."""
def __init__(self, main_win):
"""Initializes the history widget and its UI.
Args:
main_win (MainWindow): Reference to the main application window.
"""
super().__init__()
self.main_win = main_win
layout = QVBoxLayout(self)
# Table to display history entries
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels(UITexts.HISTORY_TABLE_HEADER)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.verticalHeader().setVisible(False)
self.table.setSortingEnabled(True)
self.table.doubleClicked.connect(self.open_selected)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self.show_context_menu)
layout.addWidget(self.table)
toolbar = QToolBar()
layout.addWidget(toolbar)
clear_action = QAction(QIcon.fromTheme("user-trash"),
UITexts.HISTORY_BTN_CLEAR_ALL_TOOLTIP, self)
clear_action.triggered.connect(self.clear_all)
toolbar.addAction(clear_action)
delete_action = QAction(QIcon.fromTheme("edit-delete"),
UITexts.HISTORY_BTN_DELETE_SELECTED_TOOLTIP, self)
delete_action.triggered.connect(self.delete_selected)
toolbar.addAction(delete_action)
delete_older_action = QAction(QIcon.fromTheme("edit-clear"),
UITexts.HISTORY_BTN_DELETE_OLDER_TOOLTIP, self)
delete_older_action.triggered.connect(self.delete_older)
toolbar.addAction(delete_older_action)
self.refresh_list()
def resizeEvent(self, event):
"""Adjusts column widths on resize."""
width = self.table.viewport().width()
self.table.setColumnWidth(0, int(width * 0.80))
super().resizeEvent(event)
def refresh_list(self):
"""Reloads the history from the main window's data."""
self.table.setSortingEnabled(False)
self.table.setRowCount(0)
# Filter invalid items to avoid crashes and empty rows
history = [e for e in self.main_win.full_history
if isinstance(e, dict) and e.get('path')]
self.table.setRowCount(len(history))
for i, entry in enumerate(history):
raw_path = entry.get('path', '')
text = raw_path.replace("search:/", "").replace("file:/", "")
icon_name = "system-search"
if raw_path.startswith("file:/"):
path = raw_path[6:]
if os.path.isdir(os.path.expanduser(path)):
icon_name = "folder"
else:
icon_name = "image-x-generic"
elif raw_path.startswith("layout:/"):
icon_name = "view-grid"
text = text.replace("layout:/", "")
elif raw_path.startswith("search:/"):
icon_name = "system-search"
item_name = QTableWidgetItem(text)
item_name.setIcon(QIcon.fromTheme(icon_name))
item_name.setData(Qt.UserRole, raw_path)
item_date = QTableWidgetItem(entry.get('date', ''))
self.table.setItem(i, 0, item_name)
self.table.setItem(i, 1, item_date)
self.table.setSortingEnabled(True)
def open_selected(self):
"""Opens the path/search from the selected history item."""
row = self.table.currentRow()
if row >= 0:
# Use UserRole if available (contains full raw path), else fallback to text
path = self.table.item(row, 0).data(Qt.UserRole)
if not path:
path = self.table.item(row, 0).text()
self.main_win.process_term(path)
def show_context_menu(self, pos):
"""Shows a context menu for the history table."""
item = self.table.itemAt(pos)
if not item:
return
menu = QMenu(self)
delete_action = menu.addAction(QIcon.fromTheme("edit-delete"),
UITexts.DELETE)
action = menu.exec(self.table.mapToGlobal(pos))
if action == delete_action:
self.table.setCurrentItem(item)
self.delete_selected()
def clear_all(self):
"""Clears the entire history after confirmation."""
if QMessageBox.question(self,
UITexts.HISTORY_CLEAR_ALL_TITLE,
UITexts.HISTORY_CLEAR_ALL_TEXT, QMessageBox.Yes
| QMessageBox.No) == QMessageBox.Yes:
self.main_win.full_history = []
self.main_win.save_full_history()
self.refresh_list()
def delete_selected(self):
"""Deletes the currently selected entry from the history."""
row = self.table.currentRow()
if row >= 0:
item = self.table.item(row, 0)
path = item.data(Qt.UserRole)
if not path:
path = item.text()
# Safely filter history handling potentially corrupted items
self.main_win.full_history = [
x for x in self.main_win.full_history
if isinstance(x, dict) and x.get('path') != path]
self.main_win.save_full_history()
self.refresh_list()
def delete_older(self):
"""Deletes the selected history entry and all entries older than it."""
row = self.table.currentRow()
if row >= 0:
# The visual row might not match the list index if sorted
item = self.table.item(row, 0)
path = item.data(Qt.UserRole)
if not path:
path = item.text()
idx = -1
# Find the actual index in the unsorted full_history list
for i, entry in enumerate(self.main_win.full_history):
if isinstance(entry, dict) and entry.get('path') == path:
idx = i
break
if idx != -1:
# Delete from this index to the end (older entries)
del self.main_win.full_history[idx:]
self.main_win.save_full_history()
self.refresh_list()
class RatingStar(QLabel):
"""An individual star label for the rating widget."""
# Emits the star index (1-5)
clicked = Signal(int)
def __init__(self, index, parent=None):
super().__init__(parent)
self.index = index
self.setCursor(Qt.PointingHandCursor)
def mousePressEvent(self, event):
"""Handles the click to emit its own index."""
if event.button() == Qt.LeftButton:
self.clicked.emit(self.index)
super().mousePressEvent(event)
class RatingWidget(QWidget):
"""A widget to view and edit file ratings."""
rating_updated = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.file_paths = []
self._current_rating = 0
# Icons and colors
self.star_full = QIcon.fromTheme("rating_full")
self.star_half = QIcon.fromTheme("rating_half")
self.star_empty = QIcon.fromTheme("rating_empty")
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
layout.setSpacing(2)
rating_layout = QHBoxLayout()
rating_layout.addWidget(QLabel(UITexts.INFO_RATING_LABEL))
self.stars = []
for i in range(1, 6):
star_label = RatingStar(i, self)
star_label.clicked.connect(self.on_star_clicked)
self.stars.append(star_label)
rating_layout.addWidget(star_label)
rating_layout.addStretch()
layout.addLayout(rating_layout)
self.btn_apply = QPushButton(UITexts.TAG_APPLY_CHANGES)
self.btn_apply.clicked.connect(self.save_rating)
self.btn_apply.hide()
btn_container_layout = QHBoxLayout()
btn_container_layout.addStretch()
btn_container_layout.addWidget(self.btn_apply)
layout.addLayout(btn_container_layout)
self.update_stars()
def set_files(self, file_paths):
"""Sets the current files and loads rating from the first one."""
self.file_paths = file_paths if file_paths else []
self.load_rating()
self.btn_apply.hide()
def load_rating(self):
"""Loads the rating using the XattrManager."""
self._current_rating = 0
if self.file_paths:
rating_str = XattrManager.get_attribute(self.file_paths[0],
RATING_XATTR_NAME, "0")
try:
self._current_rating = int(rating_str)
except (ValueError, TypeError):
self._current_rating = 0
self.update_stars()
@Slot(int)
def on_star_clicked(self, star_index):
"""
Handles a click on a star to cycle its state and update the rating.
The cycle is: OFF -> FULL -> HALF -> OFF.
"""
rating_for_half = star_index * 2 - 1
rating_for_full = star_index * 2
rating_previous = (star_index - 1) * 2
current_rating = self._current_rating
if current_rating > rating_for_full:
# If a higher star is active, clicking a lower one sets the rating
# to "full" of the clicked star.
self._current_rating = rating_for_full
elif current_rating == rating_for_full:
# The star is full: cycle to half.
self._current_rating = rating_for_half
elif current_rating == rating_for_half:
# The star is half: cycle to off.
self._current_rating = rating_previous
else: # current_rating < rating_for_half
# The star is off: cycle to full.
self._current_rating = rating_for_full
self.update_stars()
self.btn_apply.show()
def update_stars(self):
"""Updates the appearance of the 5 stars according to the rating."""
rating = self._current_rating
pixmap_size = self.fontMetrics().height() + 8
# Get base pixmaps from the theme
full_pixmap = self.star_full.pixmap(pixmap_size, pixmap_size)
half_pixmap = self.star_half.pixmap(pixmap_size, pixmap_size)
empty_pixmap = self.star_empty.pixmap(pixmap_size, pixmap_size)
for i, star_label in enumerate(self.stars):
star_value = i * 2 + 2
if rating >= star_value:
star_label.setPixmap(full_pixmap)
elif rating == star_value - 1:
star_label.setPixmap(half_pixmap)
else:
star_label.setPixmap(empty_pixmap)
def save_rating(self):
"""Saves the current rating using the XattrManager."""
if not self.file_paths:
return
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
value_to_set = str(self._current_rating) \
if self._current_rating > 0 else None
for path in self.file_paths:
XattrManager.set_attribute(path, RATING_XATTR_NAME, value_to_set)
self.btn_apply.hide()
self.rating_updated.emit()
except IOError as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
finally:
QApplication.restoreOverrideCursor()
class CircularProgressBar(QWidget):
"""A circular progress bar widget."""
def __init__(self, parent=None):
super().__init__(parent)
self._value = 0
self._custom_color = None
# Match the height of other status bar widgets like buttons
self.setFixedSize(22, 22)
self.setToolTip(f"{self._value}%")
def setCustomColor(self, color):
"""Sets a custom color for the progress arc. Pass None to use default."""
self._custom_color = color
self.update()
def setValue(self, value):
"""Sets the progress value (0-100)."""
if self._value != value:
self._value = max(0, min(100, value))
self.setToolTip(f"{self._value}%")
self.update() # Trigger a repaint
def value(self):
"""Returns the current progress value."""
return self._value
def paintEvent(self, event):
"""Paints the circular progress bar."""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# Use the widget's rectangle, with a small margin
rect = self.rect().adjusted(2, 2, -2, -2)
# 1. Draw the background circle (the track)
# Use a color from the palette for theme-awareness
track_color = self.palette().color(self.backgroundRole()).darker(130)
painter.setPen(QPen(track_color, 2))
painter.drawEllipse(rect)
# 2. Draw the foreground arc (the progress)
if self._value > 0:
# Use the palette's highlight color for the progress arc
if self._custom_color:
progress_color = self._custom_color
else:
progress_color = self.palette().color(QPalette.Highlight)
pen = QPen(progress_color, 2)
pen.setCapStyle(Qt.RoundCap)
painter.setPen(pen)
# Angles are in 1/16th of a degree.
# 0 degrees is at the 3 o'clock position. We start at 12 o'clock (90 deg).
start_angle = 90 * 16
# Span is negative for clockwise. 360 degrees for 100%.
span_angle = -int(self._value * 3.6 * 16)
painter.drawArc(rect, start_angle, span_angle)
class FaceNameInputWidget(QWidget):
"""
A widget for entering names that maintains a history of the last N used names.
It features autocomplete and is sorted by recent usage (MRU).
"""
name_accepted = Signal(str)
def __init__(self, main_win, parent=None, region_type="Face"):
"""Initializes the widget with a history based on configuration."""
super().__init__(parent)
self.main_win = main_win
self.region_type = region_type
# Usamos deque para gestionar el historial de forma eficiente con un máximo
# configurable de elementos.
max_items = APP_CONFIG.get("faces_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
if self.region_type == "Pet":
max_items = APP_CONFIG.get("pets_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
elif self.region_type == "Object":
max_items = APP_CONFIG.get("object_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
elif self.region_type == "Landmark":
max_items = APP_CONFIG.get("landmark_menu_max_items",
FACES_MENU_MAX_ITEMS_DEFAULT)
self.history = deque(maxlen=max_items)
self.name_to_tags_map = {}
self._setup_ui()
self._connect_signals()
def _setup_ui(self):
"""Configures the user interface of the widget."""
self.name_combo = QComboBox()
self.name_combo.setEditable(True)
self.name_combo.setInsertPolicy(QComboBox.NoInsert)
self.name_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.name_combo.setToolTip(UITexts.FACE_NAME_TOOLTIP)
self.name_combo.lineEdit().setClearButtonEnabled(True)
# 2. Completer para la funcionalidad de autocompletado.
self.completer = QCompleter(self)
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
self.completer.setFilterMode(Qt.MatchContains)
self.completer.setCompletionMode(QCompleter.PopupCompletion)
self.model = QStringListModel(self)
self.completer.setModel(self.model)
self.name_combo.setCompleter(self.completer)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self.name_combo)
def _connect_signals(self):
"""Connects signals to slots."""
self.name_combo.lineEdit().returnPressed.connect(self._on_accept)
self.name_combo.activated.connect(self._on_accept)
def _on_accept(self):
"""
Triggered when Enter is pressed or an item is selected.
Emits the `name_accepted` signal.
"""
entered_name = self.name_combo.currentText().strip()
if not entered_name:
return
matches = self.name_to_tags_map.get(entered_name, [])
final_tag = None
if len(matches) == 0:
reply = QMessageBox.question(
self, UITexts.CREATE_TAG_TITLE,
UITexts.CREATE_TAG_TEXT.format(entered_name),
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
if self.region_type == "Pet":
parent_tags_str = APP_CONFIG.get("pet_tags", "Pet")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Pet"
dialog_title = UITexts.NEW_PET_TAG_TITLE
dialog_text = UITexts.NEW_PET_TAG_TEXT
elif self.region_type == "Object":
parent_tags_str = APP_CONFIG.get("object_tags", "Object")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Object"
dialog_title = UITexts.NEW_OBJECT_TAG_TITLE
dialog_text = UITexts.NEW_OBJECT_TAG_TEXT
elif self.region_type == "Landmark":
parent_tags_str = APP_CONFIG.get("landmark_tags", "Landmark")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Landmark"
dialog_title = UITexts.NEW_LANDMARK_TAG_TITLE
dialog_text = UITexts.NEW_LANDMARK_TAG_TEXT
else:
parent_tags_str = APP_CONFIG.get("person_tags", "Person")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Person"
dialog_title = UITexts.NEW_PERSON_TAG_TITLE
dialog_text = UITexts.NEW_PERSON_TAG_TEXT
default_parent = parent_tags_str.split(',')[0].strip()
suggested_tag = f"{default_parent}/{entered_name}"
new_full_tag, ok = QInputDialog.getText(
self, dialog_title, dialog_text, QLineEdit.Normal, suggested_tag)
if ok and new_full_tag:
final_tag = new_full_tag.strip()
elif len(matches) == 1:
final_tag = matches[0]
else:
chosen_tag, ok = QInputDialog.getItem(
self, UITexts.SELECT_TAG_TITLE,
UITexts.SELECT_TAG_TEXT.format(entered_name),
matches, 0, False)
if ok and chosen_tag:
final_tag = chosen_tag
if final_tag:
self.update_history(final_tag)
self.name_accepted.emit(final_tag)
def update_history(self, full_tag_path: str):
"""
Updates the history. Moves the used name to the top of the list (MRU -
Most Recently Used).
"""
if not full_tag_path:
return
if full_tag_path in self.history:
self.history.remove(full_tag_path)
self.history.appendleft(full_tag_path)
def _update_models(self, display_names):
"""
Updates the models of the QComboBox and QCompleter with the current history.
"""
self.model.setStringList(display_names)
self.name_combo.blockSignals(True)
current_text = self.name_combo.currentText()
self.name_combo.clear()
self.name_combo.addItems(display_names)
self.name_combo.setEditText(current_text)
self.name_combo.blockSignals(False)
def load_data(self, mru_history: list):
"""Loads person names from global tags and combines them with MRU history."""
self.history.clear()
if mru_history:
# Prevent MRU eviction if history is larger than maxlen
# We take the first N items (most recent) to ensure they fit.
items_to_load = mru_history[:self.history.maxlen] \
if self.history.maxlen is not None else mru_history
for full_tag in items_to_load:
if full_tag and isinstance(full_tag, str):
self.history.append(full_tag)
all_tags = []
if self.main_win and hasattr(self.main_win, 'tag_edit_widget'):
all_tags = self.main_win.tag_edit_widget.available_tags
if self.region_type == "Pet":
parent_tags_str = APP_CONFIG.get("pet_tags", "Pet")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Pet"
elif self.region_type == "Object":
parent_tags_str = APP_CONFIG.get("object_tags", "Object")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Object"
elif self.region_type == "Landmark":
parent_tags_str = APP_CONFIG.get("landmark_tags", "Landmark")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Landmark"
else:
parent_tags_str = APP_CONFIG.get("person_tags", "Person")
if not parent_tags_str or not parent_tags_str.strip():
parent_tags_str = "Person"
person_tag_parents = [p.strip() + '/' for p in parent_tags_str.split(',')
if p.strip()]
self.name_to_tags_map.clear()
all_person_short_names = set()
# Combine all available tags with the user's history for a complete list
tags_to_process = set(all_tags) | set(self.history)
for tag in tags_to_process:
is_valid = False
# Always accept tags explicitly in history
if tag in self.history:
is_valid = True
else:
for parent in person_tag_parents:
if tag.startswith(parent):
is_valid = True
break
if is_valid:
short_name = tag.split('/')[-1]
if short_name:
all_person_short_names.add(short_name)
if short_name not in self.name_to_tags_map:
self.name_to_tags_map[short_name] = []
# Ensure no duplicate full tags are added for a short name
if tag not in self.name_to_tags_map[short_name]:
self.name_to_tags_map[short_name].append(tag)
# The display list is built from history first (for MRU order),
# then supplemented with all other known person names.
display_names = [tag.split('/')[-1] for tag in self.history]
for short_name in sorted(list(all_person_short_names)):
if short_name not in display_names:
display_names.append(short_name)
self._update_models(display_names)
def get_history(self) -> list:
"""Returns the current history list, sorted by most recent."""
return list(self.history)
def clear(self):
"""Clears the editor text."""
self.name_combo.clearEditText()
class CommentWidget(QWidget):
"""A widget to view and edit the 'user.comment' extended attribute."""
def __init__(self, parent=None):
super().__init__(parent)
self.file_paths = []
self._original_comment = ""
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
layout.addWidget(QLabel(UITexts.INFO_COMMENT_LABEL))
self.comment_edit = QTextEdit()
self.comment_edit.setAcceptRichText(False)
self.comment_edit.setPlaceholderText(UITexts.ENTER_COMMENT)
self.comment_edit.textChanged.connect(self.on_text_changed)
layout.addWidget(self.comment_edit)
self.btn_apply = QPushButton(UITexts.COMMENT_APPLY_CHANGES)
self.btn_apply.clicked.connect(self.save_comment)
self.btn_apply.hide()
btn_container_layout = QHBoxLayout()
btn_container_layout.addStretch()
btn_container_layout.addWidget(self.btn_apply)
layout.addLayout(btn_container_layout)
def set_files(self, file_paths):
"""Sets the file paths and loads the comment from the first one."""
self.file_paths = file_paths if file_paths else []
self.load_comment()
self.btn_apply.hide()
def load_comment(self):
"""Loads the comment using the XattrManager."""
self.comment_edit.blockSignals(True)
comment = ""
if self.file_paths:
comment = XattrManager.get_attribute(
self.file_paths[0], XATTR_COMMENT_NAME, "")
self._original_comment = comment
self.comment_edit.setText(comment)
self.comment_edit.blockSignals(False)
def on_text_changed(self):
"""Shows the apply button if the text has changed."""
self.btn_apply.setVisible(
self.comment_edit.toPlainText() != self._original_comment)
def save_comment(self):
"""Saves the comment using the XattrManager."""
if not self.file_paths:
return
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
new_comment = self.comment_edit.toPlainText()
value_to_set = new_comment if new_comment.strip() else None
for path in self.file_paths:
XattrManager.set_attribute(path, XATTR_COMMENT_NAME, value_to_set)
self._original_comment = new_comment
self.btn_apply.hide()
except IOError as e:
QMessageBox.critical(self, UITexts.ERROR, str(e))
finally:
QApplication.restoreOverrideCursor()