This commit is contained in:
Ignacio Serantes
2026-03-25 12:18:19 +01:00
parent 0349155fd2
commit 56ef674d4a
9 changed files with 641 additions and 455 deletions

View File

@@ -14,7 +14,7 @@ Classes:
MainWindow: The main application window containing the thumbnail grid and docks.
"""
__appname__ = "BagheeraView"
__version__ = "0.9.11"
__version__ = "0.9.12"
__author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net"
__license__ = "LGPL"
@@ -41,10 +41,10 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import (
QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette,
QStandardItemModel, QStandardItem, QColor, QPixmap, QPixmapCache, QPainter,
QKeySequence, QAction, QActionGroup
QKeySequence, QAction, QActionGroup, QImage
)
from PySide6.QtCore import (
Qt, QPoint, QUrl, QObject, QEvent, QTimer, QMimeData, QByteArray,
Qt, QPoint, QUrl, QObject, QEvent, QTimer, QByteArray,
QItemSelection, QSortFilterProxyModel, QItemSelectionModel, QRect, QSize,
QThread, QPersistentModelIndex, QModelIndex
)
@@ -1483,9 +1483,9 @@ class MainWindow(QMainWindow):
mw_data = data.get("main_window", {})
# Restore main window geometry and state (including docks)
if "geometry" in mw_data:
g = mw_data["geometry"]
self.setGeometry(g["x"], g["y"], g["w"], g["h"])
# if "geometry" in mw_data:
# g = mw_data["geometry"]
# self.setGeometry(g["x"], g["y"], g["w"], g["h"])
selected_path = mw_data.get("selected_path")
select_paths = [selected_path] if selected_path else None
@@ -2621,242 +2621,247 @@ class MainWindow(QMainWindow):
# layout properties (grid size, uniform items) are in sync with the
# selected mode before any model rebuild.
self._suppress_updates = True
index = self.view_mode_combo.currentIndex()
selected_paths = []
try:
index = self.view_mode_combo.currentIndex()
self._model_update_queue.clear()
self._model_update_timer.stop()
self._model_update_queue.clear()
self._model_update_timer.stop()
# Update proxy model flags to ensure they match the UI state
self.proxy_model.group_by_folder = (index == 1)
self.proxy_model.group_by_day = (index == 2)
self.proxy_model.group_by_week = (index == 3)
self.proxy_model.group_by_month = (index == 4)
self.proxy_model.group_by_year = (index == 5)
self.proxy_model.group_by_rating = (index == 6)
# Update proxy model flags to ensure they match the UI state
self.proxy_model.group_by_folder = (index == 1)
self.proxy_model.group_by_day = (index == 2)
self.proxy_model.group_by_week = (index == 3)
self.proxy_model.group_by_month = (index == 4)
self.proxy_model.group_by_year = (index == 5)
self.proxy_model.group_by_rating = (index == 6)
is_grouped = index > 0
self.thumbnail_view.setUniformItemSizes(not is_grouped)
if is_grouped:
self.thumbnail_view.setGridSize(QSize())
else:
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
is_grouped = index > 0
self.thumbnail_view.setUniformItemSizes(not is_grouped)
if is_grouped:
self.thumbnail_view.setGridSize(QSize())
else:
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
# Preserve selection
selected_paths = self.get_selected_paths()
# Preserve selection
selected_paths = self.get_selected_paths()
mode = self.sort_combo.currentText()
rev = "" in mode
sort_by_name = "Name" in mode
mode = self.sort_combo.currentText()
rev = "" in mode
sort_by_name = "Name" in mode
# 2. Sort the collected data. Python's sort is stable, so we apply sorts
# from least specific to most specific.
# 2. Sort the collected data. Python's sort is stable, so we apply sorts
# from least specific to most specific.
# First, sort by the user's preference (name or date).
def user_sort_key(data_tuple):
path, _, mtime, _, _, _, _ = data_tuple
if sort_by_name:
return os.path.basename(path).lower()
# Handle None mtime safely for sort
return mtime if mtime is not None else 0
# First, sort by the user's preference (name or date).
def user_sort_key(data_tuple):
path, _, mtime, _, _, _, _ = data_tuple
if sort_by_name:
return os.path.basename(path).lower()
# Handle None mtime safely for sort
return mtime if mtime is not None else 0
self.found_items_data.sort(key=user_sort_key, reverse=rev)
self.found_items_data.sort(key=user_sort_key, reverse=rev)
# 3. Rebuild the model. Disable view updates for a massive performance boost.
self.thumbnail_view.setUpdatesEnabled(False)
# 3. Rebuild the model. Disable view updates for a massive performance
# boost.
self.thumbnail_view.setUpdatesEnabled(False)
target_structure = []
target_structure = []
if not is_grouped:
# OPTIMIZATION: In Flat View, rely on Proxy Model for sorting.
# This avoids expensive O(N) source model reshuffling/syncing on the main
# thread.
if not is_grouped:
# OPTIMIZATION: In Flat View, rely on Proxy Model for sorting.
# This avoids expensive O(N) source model reshuffling/syncing on the
# main thread.
sort_role = Qt.DisplayRole if sort_by_name else MTIME_ROLE
sort_order = Qt.DescendingOrder if rev else Qt.AscendingOrder
self.proxy_model.setSortRole(sort_role)
self.proxy_model.sort(0, sort_order)
sort_role = Qt.DisplayRole if sort_by_name else MTIME_ROLE
sort_order = Qt.DescendingOrder if rev else Qt.AscendingOrder
self.proxy_model.setSortRole(sort_role)
self.proxy_model.sort(0, sort_order)
# Only rebuild source if requested or desynchronized (e.g. first batch)
# If items were added incrementally, count matches and we skip rebuild.
if full_reset or \
self.thumbnail_model.rowCount() != len(self.found_items_data):
self.thumbnail_model.clear()
self._path_to_model_index.clear()
# Fast append of all items
for item_data in self.found_items_data:
# Only rebuild source if requested or desynchronized (e.g. first batch)
# If items were added incrementally, count matches and we skip rebuild.
if full_reset or \
self.thumbnail_model.rowCount() != len(self.found_items_data):
self.thumbnail_model.clear()
self._path_to_model_index.clear()
# Fast append of all items
for item_data in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev)
p, q, m, t, r, ino, d = item_data
new_item = self._create_thumbnail_item(
p, q, m, os.path.dirname(p), t, r, ino, d)
self.thumbnail_model.appendRow(new_item)
path = new_item.data(PATH_ROLE)
source_index = self.thumbnail_model.indexFromItem(new_item)
self._path_to_model_index[path] = \
QPersistentModelIndex(source_index)
else:
# For Grouped View, we must ensure source model order matches
# groups/headers
self.proxy_model.sort(-1) # Disable proxy sorting
if full_reset:
self.thumbnail_model.clear()
self._path_to_model_index.clear()
# Optimize grouped insertion: Decorate-Sort-Group
# 1. Decorate: Calculate group info once per item
decorated_data = []
for item in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev)
p, q, m, t, r, ino, d = item_data
new_item = self._create_thumbnail_item(
p, q, m, os.path.dirname(p), t, r, ino, d)
self.thumbnail_model.appendRow(new_item)
path = new_item.data(PATH_ROLE)
source_index = self.thumbnail_model.indexFromItem(new_item)
self._path_to_model_index[path] = \
QPersistentModelIndex(source_index)
stable_key, display_name = self._get_group_info(
item[0], item[2], item[4])
# Use empty string for None keys to ensure sortability
sort_key = stable_key if stable_key is not None else ""
decorated_data.append((sort_key, display_name, item))
# 2. Sort by group key (stable sort preserves previous user order)
is_reverse_group = not self.proxy_model.group_by_folder
decorated_data.sort(key=lambda x: x[0], reverse=is_reverse_group)
# Update master list to reflect the new group order
self.found_items_data = [x[2] for x in decorated_data]
# 3. Group and Insert
for _, group_iter in groupby(decorated_data, key=lambda x: x[0]):
group_list = list(group_iter)
if not group_list:
continue
# Extract info from the first item in the group
_, display_name_group, _ = group_list[0]
count = len(group_list)
header_text = (UITexts.GROUP_HEADER_FORMAT_SINGULAR if count == 1
else UITexts.GROUP_HEADER_FORMAT).format(
group_name=display_name_group, count=count)
# ('HEADER', (key, header_text, count))
target_structure.append(
('HEADER', (display_name_group, header_text, count)))
# Add items from the group
target_structure.extend([x[2] for x in group_list])
# 4. Synchronize model with target_structure
model_idx = 0
target_idx = 0
total_targets = len(target_structure)
new_items_batch = []
while target_idx < total_targets:
target = target_structure[target_idx]
current_item = self.thumbnail_model.item(model_idx)
if self._match_item(target, current_item):
model_idx += 1
target_idx += 1
else:
# Prepare new item
if isinstance(target, tuple) and len(target) == 2 \
and target[0] == 'HEADER':
_, (group_name, header_text, _) = target
new_item = QStandardItem()
new_item.setData('header', ITEM_TYPE_ROLE)
new_item.setData(header_text, DIR_ROLE)
new_item.setData(group_name, GROUP_NAME_ROLE)
new_item.setFlags(Qt.ItemIsEnabled)
else:
path, qi, mtime, tags, rating, inode, dev = target
new_item = self._create_thumbnail_item(
path, qi, mtime, os.path.dirname(path),
tags, rating, inode, dev)
# Detect continuous block of new items for batch insertion
new_items_batch = [new_item]
target_idx += 1
# Look ahead to see if next items are also new (not in current
# model)
# This optimization drastically reduces proxy model
# recalculations
while target_idx < total_targets:
next_target = target_structure[target_idx]
# Check if next_target matches current model position
# (re-sync)
if self._match_item(
next_target, self.thumbnail_model.item(model_idx)):
break
# If not matching, it's another new item to insert
if isinstance(next_target, tuple) \
and len(next_target) == 2 and next_target[0] == 'HEADER':
_, (h_group, h_text, _) = next_target
n_item = QStandardItem()
n_item.setData('header', ITEM_TYPE_ROLE)
n_item.setData(h_text, DIR_ROLE)
n_item.setData(h_group, GROUP_NAME_ROLE)
n_item.setFlags(Qt.ItemIsEnabled)
new_items_batch.append(n_item)
else:
p, q, m, t, r, ino, d = next_target
n_item = self._create_thumbnail_item(
p, q, m, os.path.dirname(p), t, r, ino, d)
new_items_batch.append(n_item)
target_idx += 1
# Perform batch insertion
# Optimization: Use appendRow/insertRow with the item directly
# to avoid double-signaling (rowsInserted + dataChanged) which
# forces the ProxyModel to filter every row twice.
if model_idx >= self.thumbnail_model.rowCount():
for item in new_items_batch:
self.thumbnail_model.appendRow(item)
if item.data(ITEM_TYPE_ROLE) == 'thumbnail':
path = item.data(PATH_ROLE)
source_index = \
self.thumbnail_model.indexFromItem(item)
self._path_to_model_index[path] = \
QPersistentModelIndex(source_index)
else:
for i, item in enumerate(new_items_batch):
self.thumbnail_model.insertRow(model_idx + i, item)
if item.data(ITEM_TYPE_ROLE) == 'thumbnail':
path = item.data(PATH_ROLE)
source_index = self.thumbnail_model.index(
model_idx + i, 0)
self._path_to_model_index[path] = \
QPersistentModelIndex(source_index)
model_idx += len(new_items_batch)
# Remove any remaining trailing items in the model (e.g. if list shrank)
if model_idx < self.thumbnail_model.rowCount():
for row in range(model_idx, self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(ITEM_TYPE_ROLE) == 'thumbnail':
path = item.data(PATH_ROLE)
if path in self._path_to_model_index:
# Only delete if it points to this specific row (stale)
# otherwise we might delete the index for a newly
# inserted item
p_idx = self._path_to_model_index[path]
if not p_idx.isValid() or p_idx.row() == row:
del self._path_to_model_index[path]
self.thumbnail_model.removeRows(
model_idx, self.thumbnail_model.rowCount() - model_idx)
except Exception as e:
import traceback
traceback.print_exc()
print(f"Error in rebuild_view: {e}")
finally:
self._suppress_updates = False
self.apply_filters()
self.thumbnail_view.setUpdatesEnabled(True)
self.restore_selection(selected_paths)
if selected_paths:
self.restore_selection(selected_paths)
if self.main_dock.isVisible() and \
self.tags_tabs.currentWidget() == self.filter_widget:
if not self.filter_refresh_timer.isActive():
self.filter_refresh_timer.start()
return
else:
# For Grouped View, we must ensure source model order matches groups/headers
self.proxy_model.sort(-1) # Disable proxy sorting
if full_reset:
self.thumbnail_model.clear()
self._path_to_model_index.clear()
# Optimize grouped insertion: Decorate-Sort-Group
# 1. Decorate: Calculate group info once per item
decorated_data = []
for item in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev)
stable_key, display_name = self._get_group_info(
item[0], item[2], item[4])
# Use empty string for None keys to ensure sortability
sort_key = stable_key if stable_key is not None else ""
decorated_data.append((sort_key, display_name, item))
# 2. Sort by group key (stable sort preserves previous user order)
is_reverse_group = not self.proxy_model.group_by_folder
decorated_data.sort(key=lambda x: x[0], reverse=is_reverse_group)
# Update master list to reflect the new group order
self.found_items_data = [x[2] for x in decorated_data]
# 3. Group and Insert
for _, group_iter in groupby(decorated_data, key=lambda x: x[0]):
group_list = list(group_iter)
if not group_list:
continue
# Extract info from the first item in the group
_, display_name_group, _ = group_list[0]
count = len(group_list)
header_text = (UITexts.GROUP_HEADER_FORMAT_SINGULAR if count == 1
else UITexts.GROUP_HEADER_FORMAT).format(
group_name=display_name_group, count=count)
# ('HEADER', (key, header_text, count))
target_structure.append(
('HEADER', (display_name_group, header_text, count)))
# Add items from the group
target_structure.extend([x[2] for x in group_list])
# 4. Synchronize model with target_structure
model_idx = 0
target_idx = 0
total_targets = len(target_structure)
new_items_batch = []
while target_idx < total_targets:
target = target_structure[target_idx]
current_item = self.thumbnail_model.item(model_idx)
if self._match_item(target, current_item):
model_idx += 1
target_idx += 1
else:
# Prepare new item
if isinstance(target, tuple) and len(target) == 2 \
and target[0] == 'HEADER':
_, (group_name, header_text, _) = target
new_item = QStandardItem()
new_item.setData('header', ITEM_TYPE_ROLE)
new_item.setData(header_text, DIR_ROLE)
new_item.setData(group_name, GROUP_NAME_ROLE)
new_item.setFlags(Qt.ItemIsEnabled)
else:
path, qi, mtime, tags, rating, inode, dev = target
new_item = self._create_thumbnail_item(
path, qi, mtime, os.path.dirname(path),
tags, rating, inode, dev)
# Detect continuous block of new items for batch insertion
new_items_batch = [new_item]
target_idx += 1
# Look ahead to see if next items are also new (not in current model)
# This optimization drastically reduces proxy model recalculations
while target_idx < total_targets:
next_target = target_structure[target_idx]
# Check if next_target matches current model position (re-sync)
if self._match_item(
next_target, self.thumbnail_model.item(model_idx)):
break
# If not matching, it's another new item to insert
if isinstance(next_target, tuple) and len(next_target) == 2 \
and next_target[0] == 'HEADER':
_, (h_group, h_text, _) = next_target
n_item = QStandardItem()
n_item.setData('header', ITEM_TYPE_ROLE)
n_item.setData(h_text, DIR_ROLE)
n_item.setData(h_group, GROUP_NAME_ROLE)
n_item.setFlags(Qt.ItemIsEnabled)
new_items_batch.append(n_item)
else:
p, q, m, t, r, ino, d = next_target
n_item = self._create_thumbnail_item(
p, q, m, os.path.dirname(p), t, r, ino, d)
new_items_batch.append(n_item)
target_idx += 1
# Perform batch insertion
# Optimization: Use appendRow/insertRow with the item directly to avoid
# double-signaling (rowsInserted + dataChanged) which forces the
# ProxyModel to filter every row twice.
if model_idx >= self.thumbnail_model.rowCount():
for item in new_items_batch:
self.thumbnail_model.appendRow(item)
if item.data(ITEM_TYPE_ROLE) == 'thumbnail':
path = item.data(PATH_ROLE)
source_index = self.thumbnail_model.indexFromItem(item)
self._path_to_model_index[path] = QPersistentModelIndex(
source_index)
else:
for i, item in enumerate(new_items_batch):
self.thumbnail_model.insertRow(model_idx + i, item)
if item.data(ITEM_TYPE_ROLE) == 'thumbnail':
path = item.data(PATH_ROLE)
source_index = self.thumbnail_model.index(model_idx + i, 0)
self._path_to_model_index[path] = QPersistentModelIndex(
source_index)
model_idx += len(new_items_batch)
# Remove any remaining trailing items in the model (e.g. if list shrank)
if model_idx < self.thumbnail_model.rowCount():
for row in range(model_idx, self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(ITEM_TYPE_ROLE) == 'thumbnail':
path = item.data(PATH_ROLE)
if path in self._path_to_model_index:
# Only delete if it points to this specific row (stale)
# otherwise we might delete the index for a newly inserted item
p_idx = self._path_to_model_index[path]
if not p_idx.isValid() or p_idx.row() == row:
del self._path_to_model_index[path]
self.thumbnail_model.removeRows(
model_idx, self.thumbnail_model.rowCount() - model_idx)
self._suppress_updates = False
self.apply_filters()
self.thumbnail_view.setUpdatesEnabled(True)
self.restore_selection(selected_paths)
if self.main_dock.isVisible() and \
self.tags_tabs.currentWidget() == self.filter_widget:
if not self.filter_refresh_timer.isActive():
self.filter_refresh_timer.start()
def _create_thumbnail_item(self, path, qi, mtime, dir_path,
tags, rating, inode=None, dev=None):
@@ -3452,8 +3457,6 @@ class MainWindow(QMainWindow):
self.thumbnail_view.setGridSize(self.delegate.sizeHint(None, None))
self.rebuild_view(full_reset=True)
self.update_tag_list()
self.save_config()
self.setFocus()
@@ -3954,9 +3957,15 @@ class MainWindow(QMainWindow):
clipboard_menu = menu.addMenu(QIcon.fromTheme("edit-copy"),
UITexts.CONTEXT_MENU_CLIPBOARD)
action_copy_url = clipboard_menu.addAction(QIcon.fromTheme("text-html"),
UITexts.CONTEXT_MENU_COPY_URL)
action_copy_url.triggered.connect(self.copy_file_url)
action_copy_image = clipboard_menu.addAction(QIcon.fromTheme("image-x-generic"),
UITexts.VIEWER_MENU_COPY_IMAGE)
action_copy_image.triggered.connect(self.copy_image_to_clipboard)
if len(selected_indexes) > 1:
action_copy_image.setEnabled(False)
action_copy_path = clipboard_menu.addAction(
QIcon.fromTheme("document-properties"), UITexts.VIEWER_MENU_COPY_PATH)
action_copy_path.triggered.connect(self.copy_file_path_to_clipboard)
action_copy_dir = clipboard_menu.addAction(QIcon.fromTheme("folder"),
UITexts.CONTEXT_MENU_COPY_DIR)
@@ -4127,23 +4136,30 @@ class MainWindow(QMainWindow):
msg.setArguments(["", QUrl.fromLocalFile(path).toString(), {"ask": True}])
QDBusConnection.sessionBus().call(msg, QDBus.NoBlock)
def copy_file_url(self):
"""Copies the file URL of the selected image to the clipboard."""
def copy_image_to_clipboard(self):
"""Copies the full image of the selected thumbnail to the clipboard."""
path = self.get_current_selected_path()
if not path:
return
url = QUrl.fromLocalFile(path)
mime = QMimeData()
mime.setUrls([url])
mime.setText(url.toString())
QApplication.clipboard().setMimeData(mime)
# This is a disk read, but it's on user action.
img = QImage(path)
if not img.isNull():
QApplication.clipboard().setImage(img)
def copy_file_path_to_clipboard(self):
"""Copies the file path(s) of the selected image(s) to the clipboard."""
paths = self.get_selected_paths()
if not paths:
return
QApplication.clipboard().setText("\n".join(paths))
def copy_dir_path(self):
"""Copies the directory path of the selected image to the clipboard."""
path = self.get_current_selected_path()
if not path:
"""Copies the directory path(s) of the selected image(s) to the clipboard."""
paths = self.get_selected_paths()
if not paths:
return
QApplication.clipboard().setText(os.path.dirname(path))
dir_paths = sorted(list(set(os.path.dirname(p) for p in paths)))
QApplication.clipboard().setText("\n".join(dir_paths))
def show_properties(self):
"""Shows the custom properties dialog for the selected file."""