1403 lines
56 KiB
Python
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()
|