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

4314 lines
180 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Bagheera Image Viewer - Main Application.
This is the main entry point for the Bagheera Image Viewer application. It
initializes the main window, handles application-wide shortcuts, manages the
thumbnail grid, and coordinates background scanning, caching, and image viewing.
The application uses a model-view-delegate pattern for the thumbnail grid to
efficiently handle very large collections of images.
Classes:
AppShortcutController: Global event filter for keyboard shortcuts.
MainWindow: The main application window containing the thumbnail grid and docks.
"""
__appname__ = "BagheeraView"
__version__ = "0.9.11"
__author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net"
__license__ = "LGPL"
__status__ = "Beta"
# "Prototype, Development, Alpha, Beta, Production, Stable, Deprecated"
import sys
import os
import subprocess
import json
import glob
import shutil
from datetime import datetime
from collections import deque
from itertools import groupby
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QHBoxLayout, QLineEdit,
QTextEdit, QPushButton, QFileDialog, QComboBox, QSlider, QMessageBox, QSizePolicy,
QMenu, QInputDialog, QTableWidget, QTableWidgetItem, QHeaderView, QTabWidget,
QDockWidget, QAbstractItemView, QRadioButton, QButtonGroup, QListView,
QStyledItemDelegate, QStyle, QDialog, QKeySequenceEdit, QDialogButtonBox
)
from PySide6.QtGui import (
QDesktopServices, QFont, QFontMetrics, QIcon, QTransform, QImageReader, QPalette,
QStandardItemModel, QStandardItem, QColor, QPixmap, QPixmapCache, QPainter,
QKeySequence, QAction, QActionGroup
)
from PySide6.QtCore import (
Qt, QPoint, QUrl, QObject, QEvent, QTimer, QMimeData, QByteArray,
QItemSelection, QSortFilterProxyModel, QItemSelectionModel, QRect, QSize,
QThread, QPersistentModelIndex, QModelIndex
)
from PySide6.QtDBus import QDBusConnection, QDBusMessage, QDBus
from pathlib import Path
from constants import (
APP_CONFIG, CONFIG_PATH, CURRENT_LANGUAGE, DEFAULT_GLOBAL_SHORTCUTS,
DEFAULT_VIEWER_SHORTCUTS, GLOBAL_ACTIONS, HISTORY_PATH, ICON_THEME,
ICON_THEME_FALLBACK, IMAGE_MIME_TYPES, LAYOUTS_DIR, PROG_AUTHOR,
PROG_NAME, PROG_VERSION, RATING_XATTR_NAME, SCANNER_GENERATE_SIZES,
SCANNER_SETTINGS_DEFAULTS, SUPPORTED_LANGUAGES, TAGS_MENU_MAX_ITEMS_DEFAULT,
THUMBNAILS_BG_COLOR_DEFAULT, THUMBNAILS_DEFAULT_SIZE, VIEWER_ACTIONS,
THUMBNAILS_FILENAME_COLOR_DEFAULT, THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT,
THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT, THUMBNAILS_FILENAME_LINES_DEFAULT,
THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT, THUMBNAILS_TAGS_LINES_DEFAULT,
THUMBNAILS_TAGS_COLOR_DEFAULT, THUMBNAILS_RATING_COLOR_DEFAULT,
THUMBNAILS_TAGS_FONT_SIZE_DEFAULT, THUMBNAILS_REFRESH_INTERVAL_DEFAULT,
THUMBNAIL_SIZES, XATTR_NAME, UITexts
)
import constants
from settings import SettingsDialog
from imagescanner import CacheCleaner, ImageScanner, ThumbnailCache, ThumbnailGenerator
from imageviewer import ImageViewer
from propertiesdialog import PropertiesDialog
from widgets import (
CircularProgressBar,
TagEditWidget, LayoutsWidget, HistoryWidget, RatingWidget, CommentWidget
)
from metadatamanager import XattrManager
class ShortcutHelpDialog(QDialog):
"""A dialog to display, filter, and edit keyboard shortcuts."""
def __init__(self, global_shortcuts, viewer_shortcuts, main_win):
super().__init__(main_win)
self.global_shortcuts = global_shortcuts
self.viewer_shortcuts = viewer_shortcuts
self.main_win = main_win
self.setWindowTitle(UITexts.SHORTCUTS_TITLE)
self.resize(500, 450)
layout = QVBoxLayout(self)
# Search bar
self.search_bar = QLineEdit()
self.search_bar.setPlaceholderText(UITexts.SHORTCUT_SEARCH_PLACEHOLDER)
self.search_bar.textChanged.connect(self.filter_table)
layout.addWidget(self.search_bar)
# Table
self.table = QTableWidget()
self.table.setColumnCount(2)
self.table.setHorizontalHeaderLabels([UITexts.SHORTCUTS_ACTION,
UITexts.SHORTCUTS_KEY])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1,
QHeaderView.ResizeToContents)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.doubleClicked.connect(self.edit_shortcut)
layout.addWidget(self.table)
self.populate_table()
# Close button
btn_layout = QHBoxLayout()
btn_layout.addStretch()
close_btn = QPushButton(UITexts.CLOSE)
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
def populate_table(self):
"""Fills the table with the current shortcuts."""
self.table.setRowCount(0)
shortcuts_list = []
def get_int_modifiers(mods):
try:
return int(mods)
except TypeError:
return mods.value
# Global Shortcuts
for (key, mods), val in self.global_shortcuts.items():
# val is (func, ignore, desc, category)
desc = val[2]
category = val[3] if len(val) > 3 else "Global"
seq = QKeySequence(get_int_modifiers(mods) | key)
shortcut_str = seq.toString(QKeySequence.NativeText)
shortcuts_list.append({'cat': category, 'desc': desc, 'sc': shortcut_str,
'key': (key, mods), 'src': self.global_shortcuts})
# Viewer Shortcuts
for (key, mods), (action, desc) in self.viewer_shortcuts.items():
seq = QKeySequence(get_int_modifiers(mods) | key)
shortcut_str = seq.toString(QKeySequence.NativeText)
shortcuts_list.append({'cat': "Viewer", 'desc': desc, 'sc': shortcut_str,
'key': (key, mods),
'src': self.viewer_shortcuts})
# Sort by Category then Description
shortcuts_list.sort(key=lambda x: (x['cat'], x['desc']))
current_cat = None
for item in shortcuts_list:
if item['cat'] != current_cat:
current_cat = item['cat']
# Add header row
row = self.table.rowCount()
self.table.insertRow(row)
header_item = QTableWidgetItem(current_cat)
header_item.setFlags(Qt.ItemIsEnabled)
header_item.setBackground(QColor(60, 60, 60))
header_item.setForeground(Qt.white)
font = header_item.font()
font.setBold(True)
header_item.setFont(font)
header_item.setData(Qt.UserRole, "header")
self.table.setItem(row, 0, header_item)
self.table.setSpan(row, 0, 1, 2)
row = self.table.rowCount()
self.table.insertRow(row)
item_desc = QTableWidgetItem(item['desc'])
item_desc.setData(Qt.UserRole, (item['key'], item['src']))
item_sc = QTableWidgetItem(item['sc'])
item_sc.setData(Qt.UserRole, (item['key'], item['src']))
self.table.setItem(row, 0, item_desc)
self.table.setItem(row, 1, item_sc)
def filter_table(self, text):
"""Hides or shows table rows based on the search text."""
text = text.lower()
current_header_row = -1
category_has_visible_items = False
for row in range(self.table.rowCount()):
action_item = self.table.item(row, 0)
if not action_item:
continue
if action_item.data(Qt.UserRole) == "header":
# Process previous header visibility
if current_header_row != -1:
self.table.setRowHidden(current_header_row,
not category_has_visible_items)
current_header_row = row
category_has_visible_items = False
self.table.setRowHidden(row, False) # Show tentatively
else:
shortcut_item = self.table.item(row, 1)
if action_item and shortcut_item:
action_text = action_item.text().lower()
shortcut_text = shortcut_item.text().lower()
match = text in action_text or text in shortcut_text
self.table.setRowHidden(row, not match)
if match:
category_has_visible_items = True
# Handle last header
if current_header_row != -1:
self.table.setRowHidden(current_header_row,
not category_has_visible_items)
def edit_shortcut(self, index):
"""Handles the double-click event to allow shortcut customization."""
if not index.isValid():
return
row = index.row()
data = self.table.item(row, 0).data(Qt.UserRole)
if not data or data == "header":
return
original_key_combo, source_dict = data
current_sc_str = self.table.item(row, 1).text()
current_sequence = QKeySequence.fromString(
current_sc_str, QKeySequence.NativeText)
dialog = QDialog(self)
dialog.setWindowTitle(UITexts.SHORTCUT_EDIT_TITLE)
layout = QVBoxLayout(dialog)
layout.addWidget(
QLabel(UITexts.SHORTCUT_EDIT_LABEL.format(self.table.item(row, 0).text())))
key_edit = QKeySequenceEdit(current_sequence)
layout.addWidget(key_edit)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(dialog.accept)
button_box.rejected.connect(dialog.reject)
layout.addWidget(button_box)
if dialog.exec() == QDialog.Accepted:
new_sequence = key_edit.keySequence()
if new_sequence.isEmpty() or new_sequence.count() == 0:
return
new_key_combo = new_sequence[0]
new_key = new_key_combo.key()
new_mods = new_key_combo.keyboardModifiers()
new_key_tuple = (int(new_key), new_mods)
# Check for conflicts in the same scope
if new_key_tuple in source_dict and new_key_tuple != original_key_combo:
# Handle different value structures
val = source_dict[new_key_tuple]
# Global: (action, ignore, desc, category), Viewer: (action, desc)
if len(val) == 4:
conflict_desc = val[2]
else:
conflict_desc = val[1]
QMessageBox.warning(self, UITexts.SHORTCUT_CONFLICT_TITLE,
UITexts.SHORTCUT_CONFLICT_TEXT.format(
new_sequence.toString(QKeySequence.NativeText),
conflict_desc))
return
shortcut_data = source_dict.pop(original_key_combo)
source_dict[new_key_tuple] = shortcut_data
self.table.item(row, 1).setText(
new_sequence.toString(QKeySequence.NativeText))
new_data = (new_key_tuple, source_dict)
self.table.item(row, 0).setData(Qt.UserRole, new_data)
self.table.item(row, 1).setData(Qt.UserRole, new_data)
class AppShortcutController(QObject):
"""
Global event filter for application-wide keyboard shortcuts.
This class is installed on the QApplication instance to intercept key press
events before they reach their target widgets. This allows for defining
global shortcuts that work regardless of which widget has focus, unless
the user is typing in an input field.
"""
def __init__(self, main_win):
"""Initializes the shortcut controller.
Args:
main_win (MainWindow): A reference to the main application window.
"""
super().__init__()
self.main_win = main_win
self._actions = self._get_actions()
self._shortcuts = {}
self.action_to_shortcut = {}
self._register_shortcuts()
# Overwrite with loaded config if available
if hasattr(self.main_win, 'loaded_global_shortcuts') \
and self.main_win.loaded_global_shortcuts:
loaded_list = self.main_win.loaded_global_shortcuts
self._shortcuts.clear()
self.action_to_shortcut.clear()
for key_combo, val_list in loaded_list:
# Expecting [act, ignore, desc, cat]
if len(val_list) == 4:
k, m = key_combo
act, ignore, desc, cat = val_list
key_tuple = (k, Qt.KeyboardModifiers(m))
self._shortcuts[key_tuple] = (act, ignore, desc, cat)
self.action_to_shortcut[act] = key_tuple
def _get_actions(self):
"""Returns a dictionary mapping action strings to callable functions."""
return {
"quit_app": self._quit_app,
"toggle_visibility": self._toggle_visibility,
"close_all_viewers": self._close_viewers,
"load_more_images": self.main_win.load_more_images,
"load_all_images": self.main_win.load_all_images,
"save_layout": self.main_win.save_layout,
"load_layout": self.main_win.load_layout_dialog,
"open_folder": self.main_win.open_current_folder,
"move_to_trash": lambda:
self.main_win.delete_current_image(permanent=False),
"delete_permanently":
lambda: self.main_win.delete_current_image(permanent=True),
"rename_image": self._rename_image,
"refresh_content": self.main_win.refresh_content,
"first_image": lambda: self._handle_home_end(Qt.Key_Home),
"last_image": lambda: self._handle_home_end(Qt.Key_End),
"prev_page": lambda: self._handle_page_nav(Qt.Key_PageUp),
"next_page": lambda: self._handle_page_nav(Qt.Key_PageDown),
"zoom_in": lambda: self._handle_zoom(Qt.Key_Plus),
"toggle_faces": self._toggle_faces,
"zoom_out": lambda: self._handle_zoom(Qt.Key_Minus),
"select_all": self.main_win.select_all_thumbnails,
"select_none": self.main_win.select_none_thumbnails,
"invert_selection": self.main_win.invert_selection_thumbnails,
}
def _register_shortcuts(self):
"""Registers all application shortcuts from constants."""
self.action_to_shortcut.clear()
for action, (key, mods, ignore) in DEFAULT_GLOBAL_SHORTCUTS.items():
if action in GLOBAL_ACTIONS:
desc, category = GLOBAL_ACTIONS[action]
key_combo = (int(key), Qt.KeyboardModifiers(mods))
self._shortcuts[key_combo] = (action, ignore, desc, category)
self.action_to_shortcut[action] = key_combo
def eventFilter(self, obj, event):
"""Filters events to handle global key presses."""
if event.type() != QEvent.KeyPress:
return False
key = event.key()
mods = event.modifiers() & (Qt.ShiftModifier | Qt.ControlModifier |
Qt.AltModifier | Qt.MetaModifier)
# Special case: Ignore specific navigation keys when typing
focus_widget = QApplication.focusWidget()
is_typing = isinstance(focus_widget, (QComboBox, QLineEdit, QTextEdit,
QInputDialog))
# if is_typing and key in (Qt.Key_Home, Qt.Key_End, Qt.Key_Delete,
# Qt.Key_Left, Qt.Key_Right, Qt.Key_Backspace):
if is_typing:
return False
# Check if we have a handler for this combination
if (key, mods) in self._shortcuts:
action_name, ignore_if_typing, _, _ = self._shortcuts[(key, mods)]
if ignore_if_typing:
focus_widget = QApplication.focusWidget()
if isinstance(focus_widget, (QComboBox, QLineEdit, QTextEdit,
QInputDialog)):
return False
if action_name in self._actions:
self._actions[action_name]()
return True
return False
def show_help(self):
"""Displays a dialog listing all registered shortcuts."""
dialog = ShortcutHelpDialog(self._shortcuts, self.main_win.viewer_shortcuts,
self.main_win)
dialog.exec()
self.main_win.refresh_shortcuts()
# --- Action Handlers ---
def _quit_app(self):
self.main_win.perform_shutdown()
QApplication.quit()
def _toggle_visibility(self):
self.main_win.toggle_visibility()
def _close_viewers(self):
if not self.main_win.isVisible():
self.main_win.toggle_visibility()
self.main_win.close_all_viewers()
def _rename_image(self):
active_viewer = next((w for w in QApplication.topLevelWidgets()
if isinstance(w, ImageViewer)
and w.isActiveWindow()), None)
if active_viewer:
active_viewer.rename_current_image()
elif self.main_win.thumbnail_view.selectedIndexes():
self.main_win.rename_image(
self.main_win.thumbnail_view.selectedIndexes()[0].row())
def _handle_home_end(self, key):
active_viewer = next((w for w in QApplication.topLevelWidgets()
if isinstance(w, ImageViewer)
and w.isActiveWindow()), None)
if active_viewer:
if key == Qt.Key_End:
active_viewer.controller.last()
else:
active_viewer.controller.first()
active_viewer.load_and_fit_image()
elif self.main_win.proxy_model.rowCount() > 0:
if key == Qt.Key_End \
and self.main_win._scanner_last_index < \
self.main_win._scanner_total_files:
self.main_win.scanner.load_images(
self.main_win._scanner_last_index,
self.main_win._scanner_total_files -
self.main_win._scanner_last_index)
# Find the first/last actual thumbnail, skipping headers
model = self.main_win.proxy_model
count = model.rowCount()
target_row = -1
if key == Qt.Key_Home:
for row in range(count):
idx = model.index(row, 0)
if model.data(idx, ITEM_TYPE_ROLE) == 'thumbnail':
target_row = row
break
else: # End
for row in range(count - 1, -1, -1):
idx = model.index(row, 0)
if model.data(idx, ITEM_TYPE_ROLE) == 'thumbnail':
target_row = row
break
if target_row >= 0:
target_idx = model.index(target_row, 0)
self.main_win.set_selection(target_idx)
def _handle_page_nav(self, key):
active_viewer = next((w for w in QApplication.topLevelWidgets()
if isinstance(w, ImageViewer)
and w.isActiveWindow()), None)
if active_viewer:
if key == Qt.Key_PageDown:
active_viewer.next_image()
else:
active_viewer.prev_image()
elif self.main_win.isVisible():
self.main_win.handle_page_nav(key)
def _toggle_faces(self):
if self.main_win.isVisible():
self.main_win.toggle_faces()
def _handle_zoom(self, key):
active_viewer = next((w for w in QApplication.topLevelWidgets()
if isinstance(w, ImageViewer)
and w.isActiveWindow()), None)
if active_viewer:
if key == Qt.Key_Plus:
active_viewer.controller.zoom_factor *= 1.1
active_viewer.update_view(True)
elif key == Qt.Key_Minus:
active_viewer.controller.zoom_factor *= 0.9
active_viewer.update_view(True)
else:
if self.main_win.isVisible() \
and not any(isinstance(w, ImageViewer)
and w.isActiveWindow() for w in QApplication.topLevelWidgets()):
size = self.main_win.slider.value()
if key == Qt.Key_Plus:
size += 16
else:
size -= 16
self.main_win.slider.setValue(size)
# --- Data roles for the thumbnail model ---
PATH_ROLE = Qt.UserRole + 1
MTIME_ROLE = Qt.UserRole + 2
TAGS_ROLE = Qt.UserRole + 3
RATING_ROLE = Qt.UserRole + 4
ITEM_TYPE_ROLE = Qt.UserRole + 5
DIR_ROLE = Qt.UserRole + 6
INODE_ROLE = Qt.UserRole + 7
DEVICE_ROLE = Qt.UserRole + 8
IMAGE_DATA_ROLE = Qt.UserRole + 9
GROUP_NAME_ROLE = Qt.UserRole + 10
class ThumbnailDelegate(QStyledItemDelegate):
"""Draws each thumbnail in the virtualized view.
This delegate is responsible for painting each item in the QListView,
including the image, filename, rating, and tags. This is much more
performant than creating a separate widget for each thumbnail.
"""
HEADER_HEIGHT = 25
def __init__(self, parent=None):
super().__init__(parent)
self.main_win = parent
def paint(self, painter, option, index):
painter.save()
painter.setRenderHint(QPainter.SmoothPixmapTransform)
item_type = index.data(ITEM_TYPE_ROLE)
if item_type == 'header':
self.paint_header(painter, option, index)
else:
self.paint_thumbnail(painter, option, index)
painter.restore()
def paint_header(self, painter, option, index):
"""Draws a group header item."""
folder_path = index.data(DIR_ROLE)
group_name = index.data(GROUP_NAME_ROLE)
is_collapsed = group_name in self.main_win.proxy_model.collapsed_groups
prefix = "" if is_collapsed else ""
folder_name = prefix + (folder_path if folder_path else UITexts.UNKNOWN)
# Background band
painter.fillRect(option.rect, option.palette.alternateBase())
# Separator line
sep_color = option.palette.text().color()
sep_color.setAlpha(80)
painter.setPen(sep_color)
line_y = option.rect.center().y()
painter.drawLine(option.rect.left(), line_y, option.rect.right(), line_y)
# Folder name text with its own background to cover the line
font = painter.font()
font.setBold(True)
painter.setFont(font)
fm = painter.fontMetrics()
text_w = fm.horizontalAdvance(folder_name) + 20
text_bg_rect = QRect(option.rect.center().x() - text_w // 2,
option.rect.top(),
text_w,
option.rect.height())
painter.fillRect(text_bg_rect, option.palette.alternateBase())
painter.setPen(option.palette.text().color())
painter.drawText(option.rect, Qt.AlignCenter, folder_name)
def paint_thumbnail(self, painter, option, index):
"""Draws a thumbnail item with image, text, rating, and tags."""
thumb_size = self.main_win.current_thumb_size
path = index.data(PATH_ROLE)
mtime = index.data(MTIME_ROLE)
inode = index.data(INODE_ROLE)
device_id = index.data(DEVICE_ROLE)
# Optimization: Use QPixmapCache to avoid expensive QImage->QPixmap
# conversion on every paint event.
cache_key = f"thumb_{path}_{mtime}_{thumb_size}"
source_pixmap = QPixmapCache.find(cache_key)
if not source_pixmap or source_pixmap.isNull():
# Not in UI cache, try to get from main thumbnail cache (Memory/LMDB)
img, _ = self.main_win.cache.get_thumbnail(
path, requested_size=thumb_size, curr_mtime=mtime,
inode=inode, device_id=device_id, async_load=True)
if img and not img.isNull():
source_pixmap = QPixmap.fromImage(img)
QPixmapCache.insert(cache_key, source_pixmap)
else:
# Fallback: Check a separate cache key for the placeholder to avoid
# blocking the high-res update while still preventing repetitive
# conversions.
fallback_key = f"fb_{path}_{mtime}"
source_pixmap = QPixmapCache.find(fallback_key)
if not source_pixmap or source_pixmap.isNull():
# Fallback to IMAGE_DATA_ROLE (low res scan thumbnail)
img_fallback = index.data(IMAGE_DATA_ROLE)
if img_fallback and hasattr(img_fallback, 'isNull') \
and not img_fallback.isNull():
source_pixmap = QPixmap.fromImage(img_fallback)
QPixmapCache.insert(fallback_key, source_pixmap)
else:
# Fallback to the icon stored in the model
icon = index.data(Qt.DecorationRole)
if icon and not icon.isNull():
source_pixmap = icon.pixmap(thumb_size, thumb_size)
# Icons are usually internally cached by Qt, minimal
# overhead
else:
# Empty fallback if nothing exists
source_pixmap = QPixmap()
filename = index.data(Qt.DisplayRole)
tags = index.data(TAGS_ROLE) or []
rating = index.data(RATING_ROLE) or 0
# --- Rectangles and Styles ---
full_rect = QRect(option.rect)
if option.state & QStyle.State_Selected:
painter.fillRect(full_rect, option.palette.highlight())
pen_color = option.palette.highlightedText().color()
else:
pen_color = option.palette.text().color()
# --- Draw Components ---
# 1. Thumbnail Pixmap
img_bbox = QRect(full_rect.x(), full_rect.y() + 5,
full_rect.width(), thumb_size)
# Calculate destination rect maintaining aspect ratio
pic_size = source_pixmap.size()
pic_size.scale(img_bbox.size(), Qt.KeepAspectRatio)
pixmap_rect = QRect(QPoint(0, 0), pic_size)
pixmap_rect.moveCenter(img_bbox.center())
painter.drawPixmap(pixmap_rect, source_pixmap)
# Start drawing text below the thumbnail
text_y = full_rect.y() + thumb_size + 8
# 2. Filename
if APP_CONFIG.get("thumbnails_show_filename", True):
font = painter.font() # Get a copy
filename_font_size = APP_CONFIG.get("thumbnails_filename_font_size",
THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT)
font.setPointSize(filename_font_size)
painter.setFont(font)
fm = painter.fontMetrics()
line_height = fm.height()
num_lines = APP_CONFIG.get("thumbnails_filename_lines",
THUMBNAILS_FILENAME_LINES_DEFAULT)
rect_height = line_height * num_lines
text_rect = QRect(full_rect.x() + 4, text_y,
full_rect.width() - 8, rect_height)
if option.state & QStyle.State_Selected:
painter.setPen(pen_color)
else:
filename_color_str = APP_CONFIG.get("thumbnails_filename_color",
THUMBNAILS_FILENAME_COLOR_DEFAULT)
painter.setPen(QColor(filename_color_str))
# Elide text to fit approximately in the given number of lines, then wrap.
elided_text = fm.elidedText(filename.replace('\n', ' '), Qt.ElideRight,
text_rect.width() * num_lines)
flags = Qt.AlignCenter | Qt.TextWordWrap
painter.drawText(text_rect, flags, elided_text)
text_y += rect_height
# 3. Rating (stars)
if APP_CONFIG.get("thumbnails_show_rating", True):
font = option.font # Reset font to avoid compounding size changes
font.setBold(False)
# Keep rating size relative but consistent
font.setPointSize(font.pointSize() - 1)
painter.setFont(font)
num_stars = (rating + 1) // 2
stars_text = '' * num_stars + '' * (5 - num_stars)
rating_rect = QRect(full_rect.x(), text_y,
full_rect.width(), 15)
rating_color_str = APP_CONFIG.get("thumbnails_rating_color",
THUMBNAILS_RATING_COLOR_DEFAULT)
painter.setPen(QColor(rating_color_str))
painter.drawText(rating_rect, Qt.AlignCenter, stars_text)
text_y += 15
# 4. Tags
if APP_CONFIG.get("thumbnails_show_tags", True):
font = painter.font() # Reset font again
tags_font_size = APP_CONFIG.get("thumbnails_tags_font_size",
THUMBNAILS_TAGS_FONT_SIZE_DEFAULT)
font.setPointSize(tags_font_size)
painter.setFont(font)
fm = painter.fontMetrics()
line_height = fm.height()
num_lines = APP_CONFIG.get("thumbnails_tags_lines",
THUMBNAILS_TAGS_LINES_DEFAULT)
rect_height = line_height * num_lines
if option.state & QStyle.State_Selected:
painter.setPen(pen_color)
else:
tags_color_str = APP_CONFIG.get("thumbnails_tags_color",
THUMBNAILS_TAGS_COLOR_DEFAULT)
painter.setPen(QColor(tags_color_str))
display_tags = [t.split('/')[-1] for t in tags]
tags_text = ", ".join(display_tags)
tags_rect = QRect(full_rect.x() + 4, text_y,
full_rect.width() - 8, rect_height)
elided_tags = fm.elidedText(tags_text, Qt.ElideRight,
tags_rect.width() * num_lines)
painter.drawText(tags_rect, Qt.AlignCenter | Qt.TextWordWrap, elided_tags)
def sizeHint(self, option, index):
"""Provides the size hint for each item, including all elements."""
# Check for the special 'header' type only if we have a valid index
if index and index.isValid():
item_type = index.data(ITEM_TYPE_ROLE)
if item_type == 'header':
# To ensure the header item occupies a full row in the flow layout
# of the IconMode view, we set its width to the viewport's width
# minus a small margin. This prevents other items from trying to
# flow next to it.
return QSize(self.main_win.thumbnail_view.viewport().width() - 5,
self.HEADER_HEIGHT)
# Default size for a standard thumbnail item (or when index is None)
thumb_size = self.main_win.current_thumb_size
# Height: thumb + top padding
height = thumb_size + 8
# Use a temporary font to get font metrics for accurate height calculation
font = QFont(self.main_win.font())
if APP_CONFIG.get("thumbnails_show_filename", True):
font.setPointSize(APP_CONFIG.get(
"thumbnails_filename_font_size", THUMBNAILS_FILENAME_FONT_SIZE_DEFAULT))
fm = QFontMetrics(font)
num_lines = APP_CONFIG.get(
"thumbnails_filename_lines", THUMBNAILS_FILENAME_LINES_DEFAULT)
height += fm.height() * num_lines
if APP_CONFIG.get("thumbnails_show_rating", True):
height += 15 # rating rect height
if APP_CONFIG.get("thumbnails_show_tags", True):
font.setPointSize(APP_CONFIG.get(
"thumbnails_tags_font_size", THUMBNAILS_TAGS_FONT_SIZE_DEFAULT))
fm = QFontMetrics(font)
num_lines = APP_CONFIG.get(
"thumbnails_tags_lines", THUMBNAILS_TAGS_LINES_DEFAULT)
height += fm.height() * num_lines
height += 5 # bottom padding
width = thumb_size + 10
return QSize(width, height)
class ThumbnailSortFilterProxyModel(QSortFilterProxyModel):
"""Proxy model to manage filtering and sorting of thumbnails.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.main_win = parent
self._data_cache = {}
self.include_tags = set()
self.exclude_tags = set()
self.name_filter = ""
self.match_mode = "AND"
self.group_by_folder = False
self.group_by_day = False
self.group_by_week = False
self.group_by_month = False
self.group_by_year = False
self.group_by_rating = False
self.collapsed_groups = set()
def prepare_filter(self):
"""Builds a cache of paths to tags and names for faster filtering."""
if self.main_win:
# found_items_data: list of (path, qi, mtime, tags, rating, inode, dev)
# We pre-calculate sets and lowercase names for O(1) access
self._data_cache = {
item[0]: (set(item[3]) if item[3] else set(),
os.path.basename(item[0]).lower())
for item in self.main_win.found_items_data
}
else:
self._data_cache = {}
def clear_cache(self):
"""Clears the internal filter data cache."""
self._data_cache = {}
def add_to_cache(self, path, tags):
"""Adds a single item to the filter cache incrementally."""
self._data_cache[path] = (set(tags) if tags else set(),
os.path.basename(path).lower())
def filterAcceptsRow(self, source_row, source_parent):
"""Determines if a row should be visible based on current filters."""
index = self.sourceModel().index(source_row, 0, source_parent)
path = index.data(PATH_ROLE)
if not path:
item_type = index.data(ITEM_TYPE_ROLE)
if item_type == 'header':
return (self.group_by_folder or self.group_by_day or
self.group_by_week or self.group_by_month or
self.group_by_year or self.group_by_rating)
return False
# Use cached data if available, otherwise fallback to model data
tags, name_lower = self._data_cache.get(
path, (set(index.data(TAGS_ROLE) or []), os.path.basename(path).lower()))
# Filter collapsed groups
if self.main_win and (self.group_by_folder or self.group_by_day or
self.group_by_week or self.group_by_month or
self.group_by_year or self.group_by_rating):
mtime = index.data(MTIME_ROLE)
rating = index.data(RATING_ROLE)
_, group_name = self.main_win._get_group_info(path, mtime, rating)
if group_name in self.collapsed_groups:
return False
# Filter by filename
if self.name_filter and self.name_filter not in name_lower:
return False
# Filter by tags
show = False
if not self.include_tags:
show = True
elif self.match_mode == "AND":
show = self.include_tags.issubset(tags)
else: # OR mode
show = not self.include_tags.isdisjoint(tags)
# Apply exclusion filter
if show and self.exclude_tags:
if not self.exclude_tags.isdisjoint(tags):
show = False
return show
def lessThan(self, left, right):
"""Custom sorting logic for name and date."""
sort_role = self.sortRole()
left_data = self.sourceModel().data(left, sort_role)
right_data = self.sourceModel().data(right, sort_role)
if sort_role == MTIME_ROLE:
left = left_data if left_data is not None else 0
right = right_data if right_data is not None else 0
return left < right
# Default (DisplayRole) is case-insensitive name sorting
# Handle None values safely
l_str = str(left_data) if left_data is not None else ""
r_str = str(right_data) if right_data is not None else ""
return l_str.lower() < r_str.lower()
class MainWindow(QMainWindow):
"""
The main application window, which serves as the central hub for browsing
and managing images.
It features a virtualized thumbnail grid for performance, a dockable sidebar
for metadata editing and filtering, and manages the lifecycle of background
scanners and individual image viewer windows.
"""
def __init__(self, cache, args):
"""
Initializes the MainWindow.
Args:
cache (ThumbnailCache): The shared thumbnail cache instance.
args (list): Command-line arguments passed to the application.
"""
super().__init__()
self.cache = cache
self.setWindowTitle(f"{PROG_NAME} v{PROG_VERSION}")
self.set_app_icon()
self.viewer_shortcuts = {}
self.full_history = []
self.history = []
self.current_thumb_size = THUMBNAILS_DEFAULT_SIZE
self.face_names_history = []
self.pet_names_history = []
self.object_names_history = []
self.landmark_names_history = []
self.mru_tags = deque(maxlen=APP_CONFIG.get(
"tags_menu_max_items", TAGS_MENU_MAX_ITEMS_DEFAULT))
self.scanner = None
self.thumbnail_generator = None
self.show_viewer_status_bar = True
self.show_filmstrip = False
self.filmstrip_position = 'bottom' # bottom, left, top, right
self.show_faces = False
self.is_cleaning = False
self._scan_all = False
self._suppress_updates = False
self._is_loading_all = False
self._high_res_mode_active = False
self._is_loading = False
self._scanner_last_index = 0
self._scanner_total_files = 0
self._current_thumb_tier = 0
self._open_with_cache = {} # Cache for mime_type -> list of app info
self._app_info_cache = {} # Cache for desktop_file_id
self._group_info_cache = {}
self._visible_paths_cache = None # Cache for visible image paths
self._path_to_model_index = {}
# Keep references to open viewers to manage their lifecycle
self.viewers = []
# --- UI Setup ---
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
self.loaded_global_shortcuts = None
# Top bar with search and actions
top = QHBoxLayout()
self.search_input = QComboBox()
self.search_input.setEditable(True)
self.search_input.lineEdit().returnPressed.connect(self.on_search_triggered)
self.search_input.lineEdit().setClearButtonEnabled(True)
self.search_input.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
top.addWidget(self.search_input, 1) # Make search input expandable
for t, f in [(UITexts.SEARCH,
self.on_search_triggered),
(UITexts.SELECT, self.select_directory)]:
btn = QPushButton(t)
btn.clicked.connect(f)
btn.setFocusPolicy(Qt.NoFocus)
top.addWidget(btn)
self.menu_btn = QPushButton()
self.menu_btn.setIcon(QIcon.fromTheme("application-menu"))
self.menu_btn.setFocusPolicy(Qt.NoFocus)
self.menu_btn.clicked.connect(self.show_main_menu)
self.menu_btn.setFixedHeight(self.search_input.height())
top.addWidget(self.menu_btn)
layout.addLayout(top)
# --- Central Area (Virtualized Thumbnail View) ---
self.thumbnail_view = QListView()
self.thumbnail_view.setViewMode(QListView.IconMode)
self.thumbnail_view.setResizeMode(QListView.Adjust)
self.thumbnail_view.setMovement(QListView.Static)
self.thumbnail_view.setUniformItemSizes(True)
self.thumbnail_view.setSpacing(5)
self.thumbnail_view.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.thumbnail_view.setContextMenuPolicy(Qt.CustomContextMenu)
bg_color = APP_CONFIG.get("thumbnails_bg_color", THUMBNAILS_BG_COLOR_DEFAULT)
self.thumbnail_view.setStyleSheet(f"background-color: {bg_color};")
self.thumbnail_view.customContextMenuRequested.connect(self.show_context_menu)
self.thumbnail_view.doubleClicked.connect(self.on_view_double_clicked)
self.thumbnail_model = QStandardItemModel(self)
self.proxy_model = ThumbnailSortFilterProxyModel(self)
self.proxy_model.setSourceModel(self.thumbnail_model)
self.proxy_model.setDynamicSortFilter(False) # Manual invalidation
self.thumbnail_view.setModel(self.proxy_model)
self.thumbnail_view.selectionModel().selectionChanged.connect(
self.on_selection_changed)
self.delegate = ThumbnailDelegate(self)
self.thumbnail_view.setItemDelegate(self.delegate)
layout.addWidget(self.thumbnail_view)
# Bottom bar with status and controls
bot = QHBoxLayout()
self.status_lbl = QLabel(UITexts.READY)
bot.addWidget(self.status_lbl)
self.progress_bar = CircularProgressBar(self)
self.progress_bar.hide()
bot.addWidget(self.progress_bar)
# Timer to hide progress bar with delay
self.hide_progress_timer = QTimer(self)
self.hide_progress_timer.setSingleShot(True)
self.hide_progress_timer.timeout.connect(self.progress_bar.hide)
self.btn_load_more = QPushButton("+")
self.btn_load_more.setFixedSize(24, 24)
self.btn_load_more.setFocusPolicy(Qt.NoFocus)
self.btn_load_more.setToolTip(UITexts.LOAD_MORE_TOOLTIP)
self.btn_load_more.clicked.connect(self.load_more_images)
bot.addWidget(self.btn_load_more)
self.btn_load_all = QPushButton("+a")
self.btn_load_all.setFixedSize(24, 24)
self.btn_load_all.setFocusPolicy(Qt.NoFocus)
self.btn_load_all.clicked.connect(self.load_all_images)
self.update_load_all_button_state()
bot.addWidget(self.btn_load_all)
bot.addStretch()
self.filtered_count_lbl = QLabel(UITexts.FILTERED_ZERO)
bot.addWidget(self.filtered_count_lbl)
self.view_mode_combo = QComboBox()
self.view_mode_combo.addItems([
UITexts.VIEW_MODE_FLAT, UITexts.VIEW_MODE_FOLDER, UITexts.VIEW_MODE_DAY,
UITexts.VIEW_MODE_WEEK, UITexts.VIEW_MODE_MONTH, UITexts.VIEW_MODE_YEAR,
UITexts.VIEW_MODE_RATING
])
self.view_mode_combo.setFocusPolicy(Qt.NoFocus)
self.view_mode_combo.currentIndexChanged.connect(self.on_view_mode_changed)
bot.addWidget(self.view_mode_combo)
self.sort_combo = QComboBox()
self.sort_combo.addItems([UITexts.SORT_NAME_ASC, UITexts.SORT_NAME_DESC,
UITexts.SORT_DATE_ASC, UITexts.SORT_DATE_DESC])
self.sort_combo.setFocusPolicy(Qt.NoFocus)
self.sort_combo.currentIndexChanged.connect(self.on_sort_changed)
bot.addWidget(self.sort_combo)
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(64, 512)
self.slider.setSingleStep(8)
self.slider.setPageStep(8)
self.slider.setValue(THUMBNAILS_DEFAULT_SIZE)
self.slider.setMaximumWidth(100)
self.slider.setMinimumWidth(75)
self.slider.setFocusPolicy(Qt.NoFocus)
self.slider.valueChanged.connect(self.on_slider_changed)
bot.addWidget(self.slider)
self.size_label = QLabel(f"{self.current_thumb_size}px")
self.size_label.setFixedWidth(50)
bot.addWidget(self.size_label)
layout.addLayout(bot)
# --- Main Dock ---
self.main_dock = QDockWidget(UITexts.MAIN_DOCK_TITLE, self)
self.main_dock.setObjectName("MainDock")
self.main_dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
self.tags_tabs = QTabWidget()
# Tab 1: Tags (Edit)
self.tag_edit_widget = TagEditWidget(self)
self.tag_edit_widget.tags_updated.connect(self.on_tags_edited)
self.tags_tabs.addTab(self.tag_edit_widget, UITexts.TAGS_TAB)
self.tags_tabs.currentChanged.connect(self.on_tags_tab_changed)
# Tab 2: Information (Rating, Comment)
self.info_widget = QWidget()
info_layout = QVBoxLayout(self.info_widget)
self.rating_widget = RatingWidget()
self.rating_widget.rating_updated.connect(self.on_rating_edited)
info_layout.addWidget(self.rating_widget)
self.comment_widget = CommentWidget()
info_layout.addWidget(self.comment_widget)
self.tags_tabs.addTab(self.info_widget, UITexts.INFO_TAB)
self.tags_tabs.currentChanged.connect(self.on_tags_tab_changed)
# Timer for debouncing filter text input to prevent UI freezing
self.filter_input_timer = QTimer(self)
self.filter_input_timer.setSingleShot(True)
self.filter_input_timer.setInterval(300)
self.filter_input_timer.timeout.connect(self.apply_filters)
# Timer for debouncing tag list updates in the filter tab for performance
self.filter_refresh_timer = QTimer(self)
self.filter_refresh_timer.setSingleShot(True)
self.filter_refresh_timer.setInterval(1500)
self.filter_refresh_timer.timeout.connect(self.update_tag_list)
# Tab 3: Filter by Tags and Name
self.filter_widget = QWidget()
filter_layout = QVBoxLayout(self.filter_widget)
self.filter_name_input = QLineEdit()
self.filter_name_input.setPlaceholderText(UITexts.FILTER_NAME_PLACEHOLDER)
# Use debounce timer instead of direct connection
self.filter_name_input.textChanged.connect(self.filter_input_timer.start)
self.filter_name_input.setClearButtonEnabled(True)
filter_layout.addWidget(self.filter_name_input)
mode_layout = QHBoxLayout()
self.filter_mode_group = QButtonGroup(self)
rb_and = QRadioButton(UITexts.FILTER_AND)
rb_or = QRadioButton(UITexts.FILTER_OR)
rb_and.setChecked(True)
self.filter_mode_group.addButton(rb_and)
self.filter_mode_group.addButton(rb_or)
mode_layout.addWidget(rb_and)
mode_layout.addWidget(rb_or)
btn_invert = QPushButton(UITexts.FILTER_INVERT)
btn_invert.setFixedWidth(60)
btn_invert.clicked.connect(self.invert_tag_selection)
mode_layout.addWidget(btn_invert)
filter_layout.addLayout(mode_layout)
self.filter_mode_group.buttonClicked.connect(self.apply_filters)
self.filter_stats_lbl = QLabel()
self.filter_stats_lbl.setAlignment(Qt.AlignCenter)
self.filter_stats_lbl.setStyleSheet("color: gray; font-style: italic;")
self.filter_stats_lbl.hide()
filter_layout.addWidget(self.filter_stats_lbl)
self.tag_search_input = QLineEdit()
self.tag_search_input.setPlaceholderText(UITexts.TAG_SEARCH_PLACEHOLDER)
self.tag_search_input.textChanged.connect(self.filter_tags_list)
self.tag_search_input.setClearButtonEnabled(True)
filter_layout.addWidget(self.tag_search_input)
self.tags_list = QTableWidget()
self.tags_list.setColumnCount(2)
self.tags_list.setHorizontalHeaderLabels(
[UITexts.FILTER_TAG_COLUMN, UITexts.FILTER_NOT_COLUMN])
self.tags_list.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.tags_list.horizontalHeader().setSectionResizeMode(1, QHeaderView.Fixed)
self.tags_list.setColumnWidth(1, 40)
self.tags_list.verticalHeader().setVisible(False)
self.tags_list.setSelectionMode(QAbstractItemView.NoSelection)
self.tags_list.itemChanged.connect(self.on_tag_changed)
filter_layout.addWidget(self.tags_list)
self.tags_tabs.addTab(self.filter_widget, UITexts.TAG_FILTER_TAB)
# Tab 4: Layouts
self.is_xcb = QApplication.platformName() == "xcb"
if self.is_xcb:
self.layouts_tab = LayoutsWidget(self)
self.tags_tabs.addTab(self.layouts_tab, UITexts.LAYOUTS_TAB)
# Tab 5: History
self.history_tab = HistoryWidget(self)
self.tags_tabs.addTab(self.history_tab, UITexts.HISTORY_TAB)
self.main_dock.setWidget(self.tags_tabs)
self.addDockWidget(Qt.RightDockWidgetArea, self.main_dock)
self.main_dock.hide()
# Timer for debouncing UI refreshes to keep it smooth on resize
self.thumbnails_refresh_timer = QTimer(self)
self.thumbnails_refresh_timer.setSingleShot(True)
refresh_interval = APP_CONFIG.get("thumbnails_refresh_interval",
THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
self.thumbnails_refresh_timer.setInterval(refresh_interval)
self.thumbnails_refresh_timer.timeout.connect(
self.thumbnail_view.updateGeometries)
# Queue and timer for incremental model updates (prevents UI freeze
# on fast scan)
self._model_update_queue = deque()
self._model_update_timer = QTimer(self)
self._model_update_timer.setInterval(30) # ~30 FPS updates
self._model_update_timer.timeout.connect(self._process_model_update_queue)
# Data collection and model rebuilding logic
self.found_items_data = []
self._known_paths = set()
self.cache.thumbnail_loaded.connect(self.on_thumbnail_loaded)
self.rebuild_timer = QTimer(self)
self.rebuild_timer.setSingleShot(True)
self.rebuild_timer.setInterval(150) # Rebuild view periodically during scan
self.rebuild_timer.timeout.connect(self.rebuild_view)
# Timer to resume scanning after user interaction stops
self.resume_scan_timer = QTimer(self)
self.resume_scan_timer.setSingleShot(True)
self.resume_scan_timer.setInterval(400)
self.resume_scan_timer.timeout.connect(self._resume_scanning)
# # Timer for debouncing tag list updates in the filter tab for performance
# self.filter_refresh_timer = QTimer(self)
# self.filter_refresh_timer.setSingleShot(True)
# self.filter_refresh_timer.setInterval(1500)
# self.filter_refresh_timer.timeout.connect(self.update_tag_list)
# Monitor viewport resize to recalculate item layout (for headers)
self.thumbnail_view.viewport().installEventFilter(self)
self.thumbnail_view.verticalScrollBar().valueChanged.connect(
self._on_scroll_interaction)
# Initial configuration loading
self.load_config()
self.load_full_history()
self._apply_global_stylesheet()
# Set the initial thumbnail generation tier based on the loaded config size
self._current_thumb_tier = self._get_tier_for_size(self.current_thumb_size)
constants.SCANNER_GENERATE_SIZES = [self._current_thumb_tier]
if hasattr(self, 'history_tab'):
self.history_tab.refresh_list()
# Handle initial arguments passed from the command line
should_hide = False
if args:
path = " ".join(args).strip()
# Fix `file:/` URLs from file managers
if path.startswith("file:/"):
path = path[6:]
full_path = os.path.abspath(os.path.expanduser(path))
if os.path.isfile(full_path) or path.startswith("layout:/"):
# If a single file or a layout is passed, hide the main window
should_hide = True
self.handle_initial_args(args)
elif self.history:
# If no args, load the last used path or search from history
last_term = self.history[0]
# Check if the last item was a single file to decide on visibility
if last_term.startswith("file:/") or last_term.startswith("/"):
p = last_term[6:] if last_term.startswith("file:/") else last_term
if os.path.isfile(os.path.abspath(os.path.expanduser(p))):
should_hide = True
self._scan_all = False
self.process_term(last_term)
if should_hide:
self.hide()
else:
self.show()
self.setFocus()
def _process_model_update_queue(self):
"""Processes a chunk of the pending model updates."""
if not self._model_update_queue:
self._model_update_timer.stop()
return
# Process a chunk of items (e.g. 100 items per tick) to maintain responsiveness
chunk = []
try:
for _ in range(100):
chunk.append(self._model_update_queue.popleft())
except IndexError:
pass
if chunk:
self._incremental_add_to_model(chunk)
# Stop if empty, otherwise it continues on next tick
if not self._model_update_queue:
self._model_update_timer.stop()
# Ensure filter stats are updated at the end
self.apply_filters()
def _apply_global_stylesheet(self):
"""Applies application-wide stylesheets from config."""
tooltip_bg = APP_CONFIG.get("thumbnails_tooltip_bg_color",
THUMBNAILS_TOOLTIP_BG_COLOR_DEFAULT)
tooltip_fg = APP_CONFIG.get(
"thumbnails_tooltip_fg_color", THUMBNAILS_TOOLTIP_FG_COLOR_DEFAULT)
# Using QPalette is often more robust for platform-themed widgets like tooltips,
# as it's more likely to be respected by the style engine (e.g., KDE Breeze)
# than a stylesheet alone.
palette = QApplication.palette()
palette.setColor(QPalette.ToolTipBase, QColor(tooltip_bg))
palette.setColor(QPalette.ToolTipText, QColor(tooltip_fg))
QApplication.setPalette(palette)
qss = f"""
QToolTip {{
background-color: {tooltip_bg};
color: {tooltip_fg};
border: 1px solid #555;
padding: 4px;
}}
"""
QApplication.instance().setStyleSheet(qss)
def _on_scroll_interaction(self, value):
"""Pauses scanning during scroll to keep UI fluid."""
if self.scanner and self.scanner.isRunning():
self.scanner.set_paused(True)
self.resume_scan_timer.start()
def _resume_scanning(self):
"""Resumes scanning after interaction pause."""
if self.scanner:
# Prioritize currently visible images
visible_paths = self.get_visible_image_paths()
self.scanner.prioritize(visible_paths)
self.scanner.set_paused(False)
# --- Layout Management ---
def save_layout(self, target_path=None):
"""Saves the current window and viewer layout to a JSON file."""
if not self.is_xcb:
return
# Ensure the layouts directory exists
os.makedirs(LAYOUTS_DIR, exist_ok=True)
filename = None
name = ""
if target_path:
filename = target_path
name = os.path.basename(filename).replace(".layout", "")
# Confirm overwrite if the file already exists
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setWindowTitle(UITexts.LAYOUT_EXISTS_TITLE)
confirm.setText(UITexts.LAYOUT_EXISTS_TEXT.format(name))
confirm.setInformativeText(UITexts.LAYOUT_EXISTS_INFO)
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() != QMessageBox.Yes:
return
else:
# Prompt for a new layout name
while True:
name, ok = QInputDialog.getText(
self, UITexts.SAVE_LAYOUT_TITLE, UITexts.SAVE_LAYOUT_TEXT)
if not ok or not name.strip():
return
filename = os.path.join(LAYOUTS_DIR, f"{name.strip()}.layout")
if os.path.exists(filename):
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setWindowTitle(UITexts.LAYOUT_EXISTS_TITLE)
confirm.setText(UITexts.LAYOUT_EXISTS_TEXT.format(name.strip()))
confirm.setInformativeText(UITexts.LAYOUT_EXISTS_INFO)
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() == QMessageBox.Yes:
break
else:
break
# Main window data to be saved
# layout_data = {
# "main_window": {
# "visible": self.isVisible(),
# "geometry": {
# "x": self.x(), "y": self.y(),
# "w": self.width(), "h": self.height()
# },
# "search_text": self.search_input.currentText(),
# "selected_path": self.get_current_selected_path()
# },
# "viewers": []
# }
layout_data = {
"main_window": {
"visible": self.isVisible(),
"search_text": self.search_input.currentText(),
"geometry": {
"x": self.x(), "y": self.y(),
"w": self.width(), "h": self.height()
},
"window_state": self.saveState().toBase64().data().decode(),
"selected_path": self.get_current_selected_path()
},
"viewers": []
}
# Data from open viewers
# We filter to ensure the widget is still alive and visible
active_viewers = [v for v in self.viewers
if isinstance(v, ImageViewer) and v.isVisible()]
for v in active_viewers:
layout_data["viewers"].append(v.get_state())
try:
with open(filename, 'w') as f:
json.dump(layout_data, f, indent=4)
self.status_lbl.setText(UITexts.LAYOUT_SAVED.format(name))
if hasattr(self, 'layouts_tab'):
self.layouts_tab.refresh_list()
except Exception as e:
QMessageBox.critical(
self, UITexts.ERROR, UITexts.ERROR_SAVING_LAYOUT.format(e))
def load_layout_dialog(self):
"""Shows a dialog to select and load a layout."""
if not self.is_xcb:
return
if not os.path.exists(LAYOUTS_DIR):
QMessageBox.information(self, UITexts.INFO, UITexts.NO_LAYOUTS_FOUND)
return
files = glob.glob(os.path.join(LAYOUTS_DIR, "*.layout"))
if not files:
QMessageBox.information(self, UITexts.INFO, UITexts.NO_LAYOUTS_FOUND)
return
# Get clean names without extension
items = [os.path.basename(f).replace(".layout", "") for f in files]
items.sort()
item, ok = QInputDialog.getItem(
self, UITexts.LOAD_LAYOUT_TITLE, UITexts.SELECT_LAYOUT, items, 0, False)
if ok and item:
full_path = os.path.join(LAYOUTS_DIR, f"{item}.layout")
self.restore_layout(full_path)
def close_all_viewers(self):
"""Closes all currently open image viewer windows gracefully."""
for v in list(self.viewers):
try:
v.close()
except Exception:
pass
self.viewers.clear()
def restore_layout(self, filepath):
"""Restores the complete application state from a layout file."""
try:
with open(filepath, 'r') as f:
data = json.load(f)
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR,
f"Failed to load layout file: {e}")
return
# Ensure main window is visible before restoring
if not self.isVisible():
self.show()
# Clear any currently open viewers
self.close_all_viewers()
# Restore main window state
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 "window_state" in mw_data:
self.restoreState(
QByteArray.fromBase64(mw_data["window_state"].encode()))
# Restore viewers
viewers_data = data.get("viewers", [])
# Gather all unique paths from the viewers to scan
paths = []
for v_data in viewers_data:
path = v_data.get("path")
if path not in paths:
paths.append(path)
# Set scan mode and search text
self._scan_all = True
search_text = mw_data.get("search_text", "")
# Restore main window visibility
if mw_data.get("visible", True):
self.show()
self.activateWindow()
else:
self.hide()
# Create and restore each viewer
# 4. Restore Viewers
for v_data in viewers_data:
path = v_data.get("path")
if os.path.exists(path):
# Create viewer with a temporary list containing only its image.
# The full list will be synced later after the scan completes.
viewer = ImageViewer(self.cache, [path], 0, initial_tags=None,
initial_rating=0, parent=self,
restore_config=v_data, persistent=True)
# Apply saved geometry
v_geo = v_data.get("geometry")
if v_geo:
viewer.setGeometry(v_geo["x"], v_geo["y"], v_geo["w"], v_geo["h"])
self._setup_viewer_sync(viewer)
self.viewers.append(viewer)
viewer.destroyed.connect(
lambda obj=viewer: self.viewers.remove(obj)
if obj in self.viewers else None)
viewer.show()
self.status_lbl.setText(UITexts.LAYOUT_RESTORED)
# 5. Start scanning all parent directories of the images in the layout
unique_dirs = list({str(Path(p).parent) for p in paths})
for d in unique_dirs:
paths.append(d)
self.start_scan([p.strip() for p in paths if p.strip()
and os.path.exists(os.path.expanduser(p.strip()))],
select_path=mw_data.get("selected_path"))
if search_text:
self.search_input.setEditText(search_text)
# --- UI and Menu Logic ---
def show_main_menu(self):
"""Displays the main application menu."""
menu = QMenu(self)
# Actions to show different tabs in the dock
show_tags_action = menu.addAction(QIcon.fromTheme("document-properties"),
UITexts.MENU_SHOW_TAGS)
show_tags_action.triggered.connect(lambda: self.open_sidebar_tab(0))
show_info_action = menu.addAction(QIcon.fromTheme("dialog-information"),
UITexts.MENU_SHOW_INFO)
show_info_action.triggered.connect(lambda: self.open_sidebar_tab(1))
show_filter_action = menu.addAction(QIcon.fromTheme("view-filter"),
UITexts.MENU_SHOW_FILTER)
show_filter_action.triggered.connect(lambda: self.open_sidebar_tab(2))
if self.is_xcb:
show_layouts_action = menu.addAction(QIcon.fromTheme("view-grid"),
UITexts.MENU_SHOW_LAYOUTS)
l_idx = self.tags_tabs.indexOf(self.layouts_tab)
show_layouts_action.triggered.connect(lambda: self.open_sidebar_tab(l_idx))
show_history_action = menu.addAction(QIcon.fromTheme("view-history"),
UITexts.MENU_SHOW_HISTORY)
h_idx = self.tags_tabs.indexOf(self.history_tab)
show_history_action.triggered.connect(lambda: self.open_sidebar_tab(h_idx))
menu.addSeparator()
# Cache management actions
count, size = self.cache.get_cache_stats()
size_mb = size / (1024 * 1024)
disk_cache_size_mb = 0
disk_cache_path = os.path.join(constants.CACHE_PATH, "data.mdb")
if os.path.exists(disk_cache_path):
disk_cache_size_bytes = os.path.getsize(disk_cache_path)
disk_cache_size_mb = disk_cache_size_bytes / (1024 * 1024)
cache_menu = menu.addMenu(QIcon.fromTheme("drive-harddisk"), UITexts.MENU_CACHE)
clean_cache_action = cache_menu.addAction(QIcon.fromTheme("edit-clear-all"),
UITexts.MENU_CLEAN_CACHE)
clean_cache_action.triggered.connect(self.clean_thumbnail_cache)
clear_cache_action = cache_menu.addAction(
QIcon.fromTheme("user-trash-full"),
UITexts.MENU_CLEAR_CACHE.format(count, size_mb, disk_cache_size_mb))
clear_cache_action.triggered.connect(self.clear_thumbnail_cache)
menu.addSeparator()
show_shortcuts_action = menu.addAction(QIcon.fromTheme("help-keys"),
UITexts.MENU_SHOW_SHORTCUTS)
show_shortcuts_action.triggered.connect(self.show_shortcuts_help)
menu.addSeparator()
# --- Language Menu ---
language_menu = menu.addMenu(QIcon.fromTheme("preferences-desktop-locale"),
UITexts.MENU_LANGUAGE)
lang_group = QActionGroup(self)
lang_group.setExclusive(True)
lang_group.triggered.connect(self._on_language_changed)
for code, name in SUPPORTED_LANGUAGES.items():
action = QAction(name, self, checkable=True)
action.setData(code)
if code == CURRENT_LANGUAGE:
action.setChecked(True)
language_menu.addAction(action)
lang_group.addAction(action)
menu.addSeparator()
settings_action = menu.addAction(QIcon.fromTheme("preferences-system"),
UITexts.MENU_SETTINGS)
settings_action.triggered.connect(self.show_settings_dialog)
menu.addSeparator()
about_action = menu.addAction(QIcon.fromTheme("help-about"), UITexts.MENU_ABOUT)
about_action.triggered.connect(self.show_about_dialog)
menu.exec(self.menu_btn.mapToGlobal(QPoint(0, self.menu_btn.height())))
def show_about_dialog(self):
"""Shows the 'About' dialog box."""
QMessageBox.about(self, UITexts.MENU_ABOUT_TITLE.format(PROG_NAME),
UITexts.MENU_ABOUT_TEXT.format(
PROG_NAME, PROG_VERSION, PROG_AUTHOR))
def show_shortcuts_help(self):
if hasattr(self, 'shortcut_controller') and self.shortcut_controller:
self.shortcut_controller.show_help()
def show_settings_dialog(self):
dlg = SettingsDialog(self)
if dlg.exec():
# Update settings that affect the main window immediately
new_interval = APP_CONFIG.get("thumbnails_refresh_interval",
constants.THUMBNAILS_REFRESH_INTERVAL_DEFAULT)
self.thumbnails_refresh_timer.setInterval(new_interval)
new_max_tags = APP_CONFIG.get("tags_menu_max_items",
constants.TAGS_MENU_MAX_ITEMS_DEFAULT)
if self.mru_tags.maxlen != new_max_tags:
# Recreate deque with new size, preserving content
self.mru_tags = deque(self.mru_tags, maxlen=new_max_tags)
new_max_faces = APP_CONFIG.get("faces_menu_max_items",
constants.FACES_MENU_MAX_ITEMS_DEFAULT)
if len(self.face_names_history) > new_max_faces:
self.face_names_history = self.face_names_history[:new_max_faces]
new_bg_color = APP_CONFIG.get("thumbnails_bg_color",
constants.THUMBNAILS_BG_COLOR_DEFAULT)
self.thumbnail_view.setStyleSheet(f"background-color: {new_bg_color};")
# Reload filmstrip position so it applies to new viewers
self.filmstrip_position = APP_CONFIG.get("filmstrip_position", "bottom")
# Trigger a repaint to apply other color changes like filename color
self._apply_global_stylesheet()
self.thumbnail_view.updateGeometries()
self.thumbnail_view.viewport().update()
def open_sidebar_tab(self, index):
"""Shows the dock and switches to the specified tab index."""
self.main_dock.show()
self.tags_tabs.setCurrentIndex(index)
self.main_dock.raise_()
self.on_tags_tab_changed(index)
def refresh_shortcuts(self):
"""Saves current shortcuts configuration and updates running viewers."""
self.save_config()
for viewer in self.viewers:
if isinstance(viewer, ImageViewer):
viewer.refresh_shortcuts()
def clean_thumbnail_cache(self):
"""Starts a background thread to clean invalid entries from the cache."""
self.status_lbl.setText(UITexts.CACHE_CLEANING)
self.cache_cleaner = CacheCleaner(self.cache)
self.cache_cleaner.finished_clean.connect(self.on_cache_cleaned)
self.cache_cleaner.finished.connect(self._on_cache_cleaner_finished)
self.cache_cleaner.start()
def on_cache_cleaned(self, count):
"""Slot for when the cache cleaning is finished."""
self.status_lbl.setText(UITexts.CACHE_CLEANED.format(count))
def _on_cache_cleaner_finished(self):
"""Clears the cleaner reference only when the thread has truly exited."""
self.cache_cleaner = None
def load_more_images(self):
"""Requests the scanner to load the next batch of images."""
batch_size = APP_CONFIG.get(
"scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"])
self.request_more_images(batch_size)
def load_all_images(self):
"""Toggles the automatic loading of all remaining images."""
if self._is_loading_all:
# If already loading all, cancel it
self._is_loading_all = False
if self.scanner:
self.scanner.set_auto_load(False)
self.update_load_all_button_state()
else:
# Start loading all remaining images
remaining = self._scanner_total_files - self._scanner_last_index
if remaining > 0:
self._is_loading_all = True
if self.scanner:
self.scanner.set_auto_load(True)
self.update_load_all_button_state()
batch_size = APP_CONFIG.get(
"scan_batch_size", SCANNER_SETTINGS_DEFAULTS["scan_batch_size"])
self.request_more_images(batch_size)
def perform_shutdown(self):
"""Performs cleanup operations before the application closes."""
self.is_cleaning = True
# 1. Stop all worker threads interacting with the cache
# Signal all threads to stop first
if self.scanner:
self.scanner.stop()
if self.thumbnail_generator and self.thumbnail_generator.isRunning():
self.thumbnail_generator.stop()
# Create a list of threads to wait for
threads_to_wait = []
if self.scanner and self.scanner.isRunning():
threads_to_wait.append(self.scanner)
if self.thumbnail_generator and self.thumbnail_generator.isRunning():
threads_to_wait.append(self.thumbnail_generator)
if hasattr(self, 'cache_cleaner') and self.cache_cleaner and \
self.cache_cleaner.isRunning():
self.cache_cleaner.stop()
threads_to_wait.append(self.cache_cleaner)
# Wait for them to finish while keeping the UI responsive
if threads_to_wait:
self.status_lbl.setText(UITexts.SHUTTING_DOWN)
QApplication.setOverrideCursor(Qt.WaitCursor)
for thread in threads_to_wait:
while thread.isRunning():
QApplication.processEvents()
QThread.msleep(50) # Prevent high CPU usage
QApplication.restoreOverrideCursor()
# 2. Close the cache safely now that no threads are using it
self.cache.lmdb_close()
self.save_config()
def closeEvent(self, event):
"""Handles the main window close event to ensure graceful shutdown."""
self.perform_shutdown()
QApplication.quit()
def on_view_double_clicked(self, proxy_index):
"""Handles double-clicking on a thumbnail to open the viewer."""
item_type = self.proxy_model.data(proxy_index, ITEM_TYPE_ROLE)
if item_type == 'thumbnail':
self.open_viewer(proxy_index)
elif item_type == 'header':
group_name = self.proxy_model.data(proxy_index, GROUP_NAME_ROLE)
if group_name:
self.toggle_group_collapse(group_name)
else:
# Fallback for old headers if any
self.proxy_model.invalidate()
def get_current_selected_path(self):
"""Returns the file path of the first selected item in the view."""
selected_indexes = self.thumbnail_view.selectedIndexes()
if selected_indexes:
proxy_index = selected_indexes[0]
return self.proxy_model.data(proxy_index, PATH_ROLE)
return None
def get_all_image_paths(self):
"""Returns a list of all image paths in the source model."""
paths = []
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(ITEM_TYPE_ROLE) == 'thumbnail':
paths.append(item.data(PATH_ROLE))
return paths
def get_visible_image_paths(self):
"""Return a list of all currently visible image paths from the proxy model."""
if self._visible_paths_cache is not None:
return self._visible_paths_cache
# Optimization: Filter found_items_data in Python instead of iterating Qt model
# rows which is slow due to overhead.
paths = []
name_filter = self.proxy_model.name_filter
include_tags = self.proxy_model.include_tags
exclude_tags = self.proxy_model.exclude_tags
match_mode = self.proxy_model.match_mode
collapsed_groups = self.proxy_model.collapsed_groups
is_grouped = (self.proxy_model.group_by_folder or
self.proxy_model.group_by_day or
self.proxy_model.group_by_week or
self.proxy_model.group_by_month or
self.proxy_model.group_by_year or
self.proxy_model.group_by_rating)
for item in self.found_items_data:
# item: (path, qi, mtime, tags, rating, inode, dev)
path = item[0]
if is_grouped:
# Check collapsed groups
_, group_name = self._get_group_info(path, item[2], item[4])
if group_name in collapsed_groups:
continue
if name_filter and name_filter not in os.path.basename(path).lower():
continue
tags = set(item[3]) if item[3] else set()
show = False
if not include_tags:
show = True
elif match_mode == "AND":
show = include_tags.issubset(tags)
else: # OR mode
show = not include_tags.isdisjoint(tags)
if show and (not exclude_tags or exclude_tags.isdisjoint(tags)):
paths.append(path)
self._visible_paths_cache = paths
return paths
def keyPressEvent(self, e):
"""Handles key presses for grid navigation."""
# If in the search input, do not process grid navigation keys
if self.search_input.lineEdit().hasFocus():
return
if self.proxy_model.rowCount() == 0:
return
current_proxy_idx = self.thumbnail_view.currentIndex()
if not current_proxy_idx.isValid():
current_proxy_idx = self.proxy_model.index(0, 0)
current_vis_row = current_proxy_idx.row()
total_visible = self.proxy_model.rowCount()
grid_size = self.thumbnail_view.gridSize()
if grid_size.width() == 0:
return
cols = max(1, self.thumbnail_view.viewport().width() // grid_size.width())
next_vis_row = current_vis_row
# Calculate next position based on key press
if e.key() == Qt.Key_Right:
next_vis_row += 1
elif e.key() == Qt.Key_Left:
next_vis_row -= 1
elif e.key() == Qt.Key_Down:
next_vis_row += cols
elif e.key() == Qt.Key_Up:
next_vis_row -= cols
elif e.key() in (Qt.Key_Return, Qt.Key_Enter):
if current_proxy_idx.isValid():
self.open_viewer(current_proxy_idx)
return
else:
return
# Clamp the next index within valid bounds
if next_vis_row < 0:
next_vis_row = 0
if next_vis_row >= total_visible:
# If at the end, try to load more images
if self._scanner_last_index < self._scanner_total_files:
self.request_more_images(1)
next_vis_row = total_visible - 1
target_proxy_index = self.proxy_model.index(next_vis_row, 0)
if target_proxy_index.isValid():
self.set_selection(target_proxy_index, modifiers=e.modifiers())
def handle_page_nav(self, key):
"""Handles PageUp/PageDown navigation in the thumbnail grid."""
if self.proxy_model.rowCount() == 0:
return
current_proxy_idx = self.thumbnail_view.currentIndex()
if not current_proxy_idx.isValid():
current_proxy_idx = self.proxy_model.index(0, 0)
current_vis_row = current_proxy_idx.row()
total_visible = self.proxy_model.rowCount()
grid_size = self.thumbnail_view.gridSize()
if grid_size.width() <= 0 or grid_size.height() <= 0:
# Fallback to delegate size hint if grid size is not set
grid_size = self.delegate.sizeHint(None, None)
if grid_size.width() <= 0 or grid_size.height() <= 0:
return
# Calculate how many items fit in one page
cols = max(1, self.thumbnail_view.viewport().width() // grid_size.width())
rows_visible = max(1, self.thumbnail_view.viewport().height()
// grid_size.height())
step = cols * rows_visible
next_vis_idx = current_vis_row
if key == Qt.Key_PageUp:
next_vis_idx = max(0, current_vis_row - step)
else:
next_vis_idx = min(total_visible - 1, current_vis_row + step)
# If we try to page down past the end, load more images
if next_vis_idx == total_visible - 1 \
and self._scanner_last_index < self._scanner_total_files:
if current_vis_row + step >= total_visible:
self.request_more_images(step)
target_proxy_index = self.proxy_model.index(next_vis_idx, 0)
if target_proxy_index.isValid():
self.set_selection(target_proxy_index,
modifiers=QApplication.keyboardModifiers())
def set_selection(self, proxy_index, modifiers=Qt.NoModifier):
"""
Sets the selection in the thumbnail view, handling multi-selection.
Args:
proxy_index (QModelIndex): The index in the proxy model to select.
modifiers (Qt.KeyboardModifiers): Keyboard modifiers for selection mode.
"""
if not proxy_index.isValid():
return
selection_model = self.thumbnail_view.selectionModel()
selection_flags = QItemSelectionModel.NoUpdate
# Determine selection flags based on keyboard modifiers
if modifiers == Qt.NoModifier:
selection_flags = QItemSelectionModel.ClearAndSelect
elif modifiers & Qt.ControlModifier:
selection_flags = QItemSelectionModel.Toggle
elif modifiers & Qt.ShiftModifier:
# QListView handles range selection automatically with this flag
selection_flags = QItemSelectionModel.Select
selection_model.select(proxy_index, selection_flags)
self.thumbnail_view.setCurrentIndex(proxy_index)
self.thumbnail_view.scrollTo(proxy_index, QAbstractItemView.EnsureVisible)
def find_and_select_path(self, path_to_select):
"""Finds an item by its path in the model and selects it using a cache."""
if not path_to_select or path_to_select not in self._path_to_model_index:
return False
persistent_index = self._path_to_model_index[path_to_select]
if not persistent_index.isValid():
# The index might have become invalid (e.g., item removed)
del self._path_to_model_index[path_to_select]
return False
source_index = QModelIndex(persistent_index) # Convert back to QModelIndex
proxy_index = self.proxy_model.mapFromSource(source_index)
if proxy_index.isValid():
self.set_selection(proxy_index)
return True
return False
def toggle_visibility(self):
"""Toggles the visibility of the main window, opening a viewer if needed."""
if self.isVisible():
# Find first thumbnail to ensure there are images and to auto-select
first_thumb_idx = None
for row in range(self.proxy_model.rowCount()):
idx = self.proxy_model.index(row, 0)
if self.proxy_model.data(idx, ITEM_TYPE_ROLE) == 'thumbnail':
first_thumb_idx = idx
break
if not first_thumb_idx:
return
if not self.thumbnail_view.selectedIndexes():
self.set_selection(first_thumb_idx)
# If hiding and no viewers are open, open one for the selected image
open_viewers = [w for w in QApplication.topLevelWidgets()
if isinstance(w, ImageViewer) and w.isVisible()]
if not open_viewers and self.thumbnail_view.selectedIndexes():
self.open_viewer(self.thumbnail_view.selectedIndexes()[0])
self.hide()
elif open_viewers:
self.hide()
else:
self.show()
self.raise_()
self.activateWindow()
self.setFocus()
def delete_current_image(self, permanent=False):
"""Deletes the selected image(s), either to trash or permanently."""
selected_indexes = self.thumbnail_view.selectedIndexes()
if not selected_indexes:
return
# For now, only handle single deletion
path = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
if permanent:
# Confirm permanent deletion
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setWindowTitle(UITexts.CONFIRM_DELETE_TITLE)
confirm.setText(UITexts.CONFIRM_DELETE_TEXT)
confirm.setInformativeText(
UITexts.CONFIRM_DELETE_INFO.format(os.path.basename(path)))
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() != QMessageBox.Yes:
return
try:
if permanent:
os.remove(path)
else:
# Use 'gio trash' for moving to trash can on Linux
subprocess.run(["gio", "trash", path])
# TODO: Handle multi-selection delete
# Notify open viewers of the deletion
for w in QApplication.topLevelWidgets():
if isinstance(w, ImageViewer):
if path in w.controller.image_list:
try:
deleted_idx = w.controller.image_list.index(path)
new_list = list(w.controller.image_list)
new_list.remove(path)
w.refresh_after_delete(new_list, deleted_idx)
except (ValueError, RuntimeError):
pass # Viewer might be closing or list out of sync
source_index = self.proxy_model.mapToSource(selected_indexes[0])
if source_index.isValid():
self.thumbnail_model.removeRow(source_index.row())
if path in self._path_to_model_index:
del self._path_to_model_index[path]
# Remove from found_items_data to ensure consistency
self.found_items_data = [x for x in self.found_items_data if x[0] != path]
self._known_paths.discard(path)
# Clean up group cache
keys_to_remove = [k for k in self._group_info_cache if k[0] == path]
for k in keys_to_remove:
del self._group_info_cache[k]
# Clean up proxy model cache
if path in self.proxy_model._data_cache:
del self.proxy_model._data_cache[path]
self._visible_paths_cache = None
except Exception as e:
QMessageBox.critical(
self, UITexts.SYSTEM_ERROR, UITexts.ERROR_DELETING_FILE.format(e))
def move_current_image(self):
"""Moves the selected image to another directory via a file dialog."""
path = self.get_current_selected_path()
if not path:
return
target_dir = QFileDialog.getExistingDirectory(
self, UITexts.CONTEXT_MENU_MOVE_TO, os.path.dirname(path))
if not target_dir:
return
new_path = os.path.join(target_dir, os.path.basename(path))
if os.path.exists(new_path):
reply = QMessageBox.question(
self, UITexts.CONFIRM_OVERWRITE_TITLE,
UITexts.CONFIRM_OVERWRITE_TEXT.format(new_path),
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply != QMessageBox.Yes:
return
try:
shutil.move(path, new_path)
# Find and remove item from model
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == path:
self.thumbnail_model.removeRow(row)
if path in self._path_to_model_index:
del self._path_to_model_index[path]
break
# Remove from found_items_data to ensure consistency
self.found_items_data = [x for x in self.found_items_data if x[0] != path]
self._known_paths.discard(path)
# Clean up group cache
keys_to_remove = [k for k in self._group_info_cache if k[0] == path]
for k in keys_to_remove:
del self._group_info_cache[k]
# Clean up proxy model cache
if path in self.proxy_model._data_cache:
del self.proxy_model._data_cache[path]
self._visible_paths_cache = None
# Notify viewers
for w in QApplication.topLevelWidgets():
if isinstance(w, ImageViewer):
if path in w.controller.image_list:
new_list = list(w.controller.image_list)
new_list.remove(path)
w.refresh_after_delete(new_list, -1)
self.status_lbl.setText(UITexts.MOVED_TO.format(target_dir))
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, UITexts.ERROR_MOVE_FILE.format(e))
def copy_current_image(self):
"""Copies the selected image to another directory via a file dialog."""
path = self.get_current_selected_path()
if not path:
return
target_dir = QFileDialog.getExistingDirectory(
self, UITexts.CONTEXT_MENU_COPY_TO, os.path.dirname(path))
if not target_dir:
return
new_path = os.path.join(target_dir, os.path.basename(path))
if os.path.exists(new_path):
reply = QMessageBox.question(
self, UITexts.CONFIRM_OVERWRITE_TITLE,
UITexts.CONFIRM_OVERWRITE_TEXT.format(new_path),
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if reply != QMessageBox.Yes:
return
try:
shutil.copy2(path, new_path)
self.status_lbl.setText(UITexts.COPIED_TO.format(target_dir))
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR, UITexts.ERROR_COPY_FILE.format(e))
def rotate_current_image(self, degrees):
"""Rotates the selected image, attempting lossless rotation for JPEGs."""
path = self.get_current_selected_path()
if not path:
return
_, ext = os.path.splitext(path)
ext = ext.lower()
success = False
# Try lossless rotation for JPEGs using exiftran if available
if ext in ['.jpg', '.jpeg']:
try:
cmd = []
if degrees == 90:
cmd = ["exiftran", "-i", "-9", path]
elif degrees == -90:
cmd = ["exiftran", "-i", "-2", path]
elif degrees == 180:
cmd = ["exiftran", "-i", "-1", path]
if cmd:
subprocess.check_call(cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
success = True
except Exception:
pass # Fallback to lossy rotation
# Fallback to lossy rotation using QImage
if not success:
try:
reader = QImageReader(path)
reader.setAutoTransform(True)
img = reader.read()
if img.isNull():
return
transform = QTransform().rotate(degrees)
new_img = img.transformed(transform, Qt.SmoothTransformation)
new_img.save(path)
success = True
except Exception as e:
QMessageBox.critical(self, UITexts.ERROR,
UITexts.ERROR_ROTATE_IMAGE.format(e))
return
# Invalidate all cached thumbnails for this path. They will be regenerated
# on demand.
self.cache.invalidate_path(path)
try:
reader = QImageReader(path)
reader.setAutoTransform(True)
full_img = reader.read()
if not full_img.isNull():
# Regenerate the smallest thumbnail for immediate UI update
stat_res = os.stat(path)
new_mtime = stat_res.st_mtime
new_inode = stat_res.st_ino
new_dev = stat_res.st_dev
smallest_size = min(SCANNER_GENERATE_SIZES) \
if SCANNER_GENERATE_SIZES else THUMBNAIL_SIZES[0]
thumb_img = full_img.scaled(smallest_size, smallest_size,
Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.cache.set_thumbnail(path, thumb_img, new_mtime, smallest_size,
inode=new_inode, device_id=new_dev)
# Update model item
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == path:
item.setIcon(QIcon(QPixmap.fromImage(thumb_img)))
item.setData(new_mtime, MTIME_ROLE)
item.setData(new_inode, INODE_ROLE)
item.setData(new_dev, DEVICE_ROLE)
self._update_internal_data(path, qi=thumb_img, mtime=new_mtime,
inode=new_inode, dev=new_dev)
break
except Exception:
pass
# Update any open viewers showing this image
for w in QApplication.topLevelWidgets():
if isinstance(w, ImageViewer):
if w.controller.get_current_path() == path:
w.load_and_fit_image()
def start_scan(self, paths, sync_viewer=False, active_viewer=None,
select_path=None):
"""
Starts a new background scan for images.
Args:
paths (list): A list of file paths or directories to scan.
sync_viewer (bool): If True, avoids clearing the grid.
active_viewer (ImageViewer): A viewer to sync with the scan results.
select_path (str): A path to select automatically after the scan finishes.
"""
self.is_cleaning = True
self._suppress_updates = True
if self.scanner:
self.scanner.stop()
# Reset state for the new scan
self._is_loading_all = APP_CONFIG.get(
"scan_full_on_start", SCANNER_SETTINGS_DEFAULTS["scan_full_on_start"])
self.update_load_all_button_state()
# Clear the model if not syncing with an existing viewer
if not sync_viewer:
self.thumbnail_model.clear()
self.found_items_data = []
self._path_to_model_index.clear()
self._known_paths.clear()
self._group_info_cache.clear()
self.proxy_model.clear_cache()
self._model_update_queue.clear()
self._model_update_timer.stop()
# Stop any pending hide action from previous scan
self.hide_progress_timer.stop()
# Hide load buttons during scan
self.btn_load_more.hide()
self.btn_load_all.hide()
self.progress_bar.setValue(0)
self.progress_bar.setCustomColor(None)
self.progress_bar.show()
self.is_cleaning = False
self.scanner = ImageScanner(self.cache, paths, is_file_list=self._scan_all,
viewers=self.viewers)
if self._is_loading_all:
self.scanner.set_auto_load(True)
self._is_loading = True
self.scanner.images_found.connect(self.collect_found_images)
self.scanner.progress_percent.connect(self.update_progress_bar)
self.scanner.progress_msg.connect(self.status_lbl.setText)
self.scanner.more_files_available.connect(self.more_files_available)
self.scanner.finished_scan.connect(
lambda n: self._on_scan_finished(n, select_path))
self.scanner.start()
self._scan_all = False
def _on_scan_finished(self, n, select_path=None):
"""Slot for when the image scanner has finished."""
self._suppress_updates = False
self._scanner_last_index = self._scanner_total_files
self.btn_load_more.hide()
self.btn_load_all.hide()
# Turn green to indicate success and hide after 2 seconds
self.progress_bar.setValue(100)
self.progress_bar.setCustomColor(QColor("#2ecc71"))
self.hide_progress_timer.start(2000)
self.status_lbl.setText(UITexts.DONE_SCAN.format(n))
self.setFocus()
self._scan_all = False
# Reset 'load all' state
self._is_loading_all = False
self.update_load_all_button_state()
# Update dock widgets if visible
if self.main_dock.isVisible():
self.update_tag_list()
if self.tag_edit_widget.isVisible():
self.update_tag_edit_widget()
# Select a specific path if requested (e.g., after layout restore)
if select_path:
self.find_and_select_path(select_path)
# Final rebuild to ensure all items are correctly placed
if self.rebuild_timer.isActive():
self.rebuild_timer.stop()
self.rebuild_view()
def more_files_available(self, i, count):
"""Slot for when a batch of images has been loaded, with more available."""
self._scanner_last_index = i
self._scanner_total_files = count
self._is_loading = False
has_more = i < count
self.btn_load_more.setVisible(has_more)
self.btn_load_all.setVisible(has_more)
def request_more_images(self, amount):
"""Requests the scanner to load a specific number of additional images."""
if self._is_loading:
return
if self._scanner_last_index >= self._scanner_total_files:
return
self._is_loading = True
self.scanner.load_images(self._scanner_last_index, amount)
def _incremental_add_to_model(self, batch):
"""Appends new items directly to the model without full rebuild."""
self._visible_paths_cache = None
new_items = []
for item in batch:
path, qi, mtime, tags, rating, inode, dev = item
new_item = self._create_thumbnail_item(
path, qi, mtime, os.path.dirname(path),
tags, rating, inode, dev)
new_items.append(new_item)
if new_items:
# Disable updates briefly to prevent flickering during insertion
self.thumbnail_view.setUpdatesEnabled(False)
# Optimization: Use appendRow/insertRow with the item directly.
# This avoids the "insert empty -> set data" double-signaling which forces
# the ProxyModel to filter every row twice.
for item in new_items:
self.thumbnail_model.appendRow(item)
path = item.data(PATH_ROLE)
source_index = self.thumbnail_model.indexFromItem(item)
self._path_to_model_index[path] = QPersistentModelIndex(source_index)
self.thumbnail_view.setUpdatesEnabled(True)
def collect_found_images(self, batch) -> None:
"""Collects a batch of found image data and triggers a view rebuild.
This method adds new data to an internal list and then starts a timer
to rebuild the view in a debounced manner, improving UI responsiveness
during a scan.
Args:
batch (list): A list of tuples, where each tuple contains the data
for one found image (path, QImage, mtime, tags, rating).
"""
# Add to the data collection, avoiding duplicates
unique_batch = []
is_first_batch = len(self.found_items_data) == 0
for item in batch:
path = item[0]
if path not in self._known_paths:
self._known_paths.add(path)
# Optimization: Do not store QImage in found_items_data to save memory.
# The delegate will retrieve thumbnails from cache.
unique_batch.append(
(item[0], None, item[2], item[3], item[4], item[5], item[6]))
# Update proxy filter cache incrementally as data arrives
self.proxy_model.add_to_cache(item[0], item[3])
if unique_batch:
self.found_items_data.extend(unique_batch)
if is_first_batch:
self._suppress_updates = False
# Adjust rebuild timer interval based on total items to ensure UI
# responsiveness.
# Processing large lists takes time, so we update less frequently as the
# list grows.
total_count = len(self.found_items_data)
if total_count < 2000:
self.rebuild_timer.setInterval(150)
elif total_count < 10000:
self.rebuild_timer.setInterval(500)
else:
self.rebuild_timer.setInterval(1000)
# Optimization: If scanning and in Flat view, just append to model
# This avoids O(N) rebuilds/sorting during load
is_flat_view = (self.view_mode_combo.currentIndex() == 0)
# For the very first batch, rebuild immediately to give instant feedback.
if is_first_batch:
self.rebuild_view()
self.rebuild_timer.start()
elif is_flat_view:
# Buffer updates to avoid freezing the UI with thousands of signals
self._model_update_queue.extend(unique_batch)
if not self._model_update_timer.isActive():
self._model_update_timer.start()
# For grouped views or subsequent batches, debounce to avoid freezing.
elif not self.rebuild_timer.isActive():
self.rebuild_timer.start()
def _update_internal_data(self, path, qi=None, mtime=None, tags=None, rating=None,
inode=None, dev=None):
"""Updates the internal data list to match model changes."""
for i, item_data in enumerate(self.found_items_data):
if item_data[0] == path:
# tuple: (path, qi, mtime, tags, rating, inode, dev)
# curr_qi = item_data[1]
curr_mtime = item_data[2]
curr_tags = item_data[3]
curr_rating = item_data[4]
# Preserve inode and dev if available (indices 5 and 6)
curr_inode = item_data[5] if len(item_data) > 5 else None
curr_dev = item_data[6] if len(item_data) > 6 else None
new_qi = None
new_mtime = mtime if mtime is not None else curr_mtime
new_tags = tags if tags is not None else curr_tags
new_rating = rating if rating is not None else curr_rating
new_inode = inode if inode is not None else curr_inode
new_dev = dev if dev is not None else curr_dev
# Check if sorting/grouping keys (mtime, rating) changed
if (mtime is not None and mtime != curr_mtime) or \
(rating is not None and rating != curr_rating):
cache_key = (path, curr_mtime, curr_rating)
if cache_key in self._group_info_cache:
del self._group_info_cache[cache_key]
self.found_items_data[i] = (path, new_qi, new_mtime,
new_tags, new_rating, new_inode, new_dev)
break
def _match_item(self, target, item):
"""Checks if a data target matches a model item."""
if item is None:
return False
# Check for Header match
# target format: ('HEADER', (key, header_text, count))
if isinstance(target, tuple) and len(target) == 2 and target[0] == 'HEADER':
_, (_, header_text, _) = target
# Strict match including group name to ensure roles are updated
target_group_name = target[1][0]
return (item.data(ITEM_TYPE_ROLE) == 'header' and
item.data(GROUP_NAME_ROLE) == target_group_name and
item.data(DIR_ROLE) == header_text)
# Check for Thumbnail match
# target format: (path, qi, mtime, tags, rating, inode, dev)
# Target tuple length is now 7
if item.data(ITEM_TYPE_ROLE) == 'thumbnail' and len(target) >= 5:
return item.data(PATH_ROLE) == target[0]
return False
def _get_group_info(self, path, mtime, rating):
"""Calculates the grouping key and display name for a file.
Args:
path (str): File path.
mtime (float): Modification time.
rating (int): Rating value.
Returns:
tuple: (stable_group_key, display_name)
"""
cache_key = (path, mtime, rating)
if cache_key in self._group_info_cache:
return self._group_info_cache[cache_key]
stable_group_key = None
display_name = None
if self.proxy_model.group_by_folder:
stable_group_key = display_name = os.path.dirname(path)
elif self.proxy_model.group_by_day:
stable_group_key = display_name = datetime.fromtimestamp(
mtime).strftime("%Y-%m-%d")
elif self.proxy_model.group_by_week:
dt = datetime.fromtimestamp(mtime)
stable_group_key = dt.strftime("%Y-%W")
display_name = UITexts.GROUP_BY_WEEK_FORMAT.format(
year=dt.strftime("%Y"), week=dt.strftime("%W"))
elif self.proxy_model.group_by_month:
dt = datetime.fromtimestamp(mtime)
stable_group_key = dt.strftime("%Y-%m")
display_name = dt.strftime("%B %Y").capitalize()
elif self.proxy_model.group_by_year:
stable_group_key = display_name = datetime.fromtimestamp(
mtime).strftime("%Y")
elif self.proxy_model.group_by_rating:
r = rating if rating is not None else 0
stars = (r + 1) // 2
stable_group_key = str(stars)
display_name = UITexts.GROUP_BY_RATING_FORMAT.format(stars=stars)
self._group_info_cache[cache_key] = (stable_group_key, display_name)
return stable_group_key, display_name
def rebuild_view(self, full_reset=False):
"""
Sorts all collected image data and rebuilds the source model, inserting
headers for folder groups if required.
"""
# On startup, the view mode is not always correctly applied visually
# even if the combo box shows the correct value. This ensures the view's
# 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()
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)
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_path = self.get_current_selected_path()
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.
# 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)
# 3. Rebuild the model. Disable view updates for a massive performance boost.
self.thumbnail_view.setUpdatesEnabled(False)
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.
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:
# 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)
self._suppress_updates = False
self.apply_filters()
self.thumbnail_view.setUpdatesEnabled(True)
self.find_and_select_path(selected_path)
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.find_and_select_path(selected_path)
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):
"""Helper to create a standard item for the model."""
thumb_item = QStandardItem()
# Optimization: Do NOT create QIcon/QPixmap here.
# The delegate handles painting from cache directly.
# This avoids expensive main-thread image conversions during scanning.
# thumb_item.setIcon(QIcon(QPixmap.fromImage(qi)))
thumb_item.setText(os.path.basename(path))
tooltip_text = f"{os.path.basename(path)}\n{path}"
if tags:
display_tags = [t.split('/')[-1] for t in tags]
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(display_tags)}"
thumb_item.setToolTip(tooltip_text)
thumb_item.setEditable(False)
thumb_item.setData('thumbnail', ITEM_TYPE_ROLE)
thumb_item.setData(path, PATH_ROLE)
thumb_item.setData(mtime, MTIME_ROLE)
thumb_item.setData(dir_path, DIR_ROLE)
if qi:
thumb_item.setData(qi, IMAGE_DATA_ROLE)
# Set metadata that was loaded in the background thread
thumb_item.setData(tags, TAGS_ROLE)
thumb_item.setData(rating, RATING_ROLE)
if inode is not None and dev is not None:
thumb_item.setData(inode, INODE_ROLE)
thumb_item.setData(dev, DEVICE_ROLE)
return thumb_item
def update_progress_bar(self, value):
"""Updates the circular progress bar value."""
self.progress_bar.setValue(value)
def on_thumbnail_loaded(self, path, size):
"""Called when a thumbnail has been loaded asynchronously from DB."""
self.thumbnail_view.viewport().update()
def on_tags_tab_changed(self, index):
"""Updates the content of the sidebar dock when the active tab changes."""
widget = self.tags_tabs.widget(index)
if widget == self.tag_edit_widget:
self.tag_edit_widget.load_available_tags()
self.update_tag_edit_widget()
elif widget == self.filter_widget:
self.update_tag_list()
elif widget == self.info_widget:
self.update_info_widget()
def update_tag_edit_widget(self):
"""Updates the tag editor widget with data from the currently selected files."""
if self._suppress_updates:
return
selected_indexes = self.thumbnail_view.selectedIndexes()
if not selected_indexes:
self.tag_edit_widget.set_files_data({})
return
files_data = {}
for proxy_idx in selected_indexes:
path = proxy_idx.data(PATH_ROLE)
tags = proxy_idx.data(TAGS_ROLE)
files_data[path] = tags
self.tag_edit_widget.set_files_data(files_data)
def update_info_widget(self):
"""Updates the information widget (rating, comment) with the current file's
data."""
if self._suppress_updates:
return
selected_indexes = self.thumbnail_view.selectedIndexes()
paths = []
if selected_indexes:
for proxy_idx in selected_indexes:
path = self.proxy_model.data(proxy_idx, PATH_ROLE)
if path:
paths.append(path)
self.rating_widget.set_files(paths)
self.comment_widget.set_files(paths)
def toggle_main_dock(self):
"""Toggles the visibility of the main sidebar dock widget."""
if self.main_dock.isVisible():
self.main_dock.hide()
else:
self.update_tag_list()
self.main_dock.show()
if self.tag_edit_widget.isVisible():
self.update_tag_edit_widget()
def toggle_faces(self):
"""Toggles the global 'show_faces' state and updates open viewers."""
self.show_faces = not self.show_faces
self.save_config()
for viewer in self.viewers:
if isinstance(viewer, ImageViewer):
viewer.controller.show_faces = self.show_faces
viewer.update_view(resize_win=False)
def on_tags_edited(self, tags_per_file=None):
"""Callback to update model items after their tags have been edited."""
for proxy_idx in self.thumbnail_view.selectedIndexes():
source_idx = self.proxy_model.mapToSource(proxy_idx)
item = self.thumbnail_model.itemFromIndex(source_idx)
if item:
path = item.data(PATH_ROLE)
# Use provided tags if available, otherwise fallback to disk read
try:
if isinstance(tags_per_file, dict) and path in tags_per_file:
tags = tags_per_file[path]
else:
raw = os.getxattr(path, XATTR_NAME).decode('utf-8')
tags = sorted(
list(set(t.strip() for t in raw.split(',') if t.strip())))
item.setData(tags, TAGS_ROLE)
tooltip_text = f"{os.path.basename(path)}\n{path}"
if tags:
display_tags = [t.split('/')[-1] for t in tags]
tooltip_text += f"\n{UITexts.TAGS_TAB}: {', '.join(
display_tags)}"
item.setToolTip(tooltip_text)
except Exception:
item.setData([], TAGS_ROLE)
self._update_internal_data(path, tags=item.data(TAGS_ROLE))
# Update proxy filter cache immediately
self.proxy_model.add_to_cache(path, tags)
# Notify the view that the data has changed
self.thumbnail_model.dataChanged.emit(
source_idx, source_idx, [TAGS_ROLE])
self.update_tag_list()
self.apply_filters()
def on_rating_edited(self):
"""Callback to update a model item after its rating has been edited."""
# The rating widget acts on the selected files, so we iterate through them.
for proxy_idx in self.thumbnail_view.selectedIndexes():
source_idx = self.proxy_model.mapToSource(proxy_idx)
item = self.thumbnail_model.itemFromIndex(source_idx)
if item:
path = item.data(PATH_ROLE)
# Re-read rating from xattr to be sure of the value
new_rating = 0
try:
raw_rating = os.getxattr(path, RATING_XATTR_NAME).decode('utf-8')
new_rating = int(raw_rating)
except (OSError, ValueError, AttributeError):
pass
# Update the model data, which will trigger a view update.
item.setData(new_rating, RATING_ROLE)
self._update_internal_data(path, rating=new_rating)
def update_tag_list(self):
"""Updates the list of available tags in the filter panel from all loaded
items."""
if not hasattr(self, 'tags_list'):
return
if self._suppress_updates:
return
checked_tags = set()
not_tags = set()
# Preserve current filter state
for i in range(self.tags_list.rowCount()):
item_tag = self.tags_list.item(i, 0)
item_not = self.tags_list.item(i, 1)
tag_name = item_tag.data(Qt.UserRole) if item_tag else None
if not tag_name and item_tag:
tag_name = item_tag.text()
if item_tag and item_tag.checkState() == Qt.Checked:
checked_tags.add(tag_name)
if item_tag and item_not and item_not.checkState() == Qt.Checked:
not_tags.add(tag_name)
self.tags_list.blockSignals(True)
self.tags_list.setRowCount(0)
# Gather all unique tags from found_items_data (Optimized)
tag_counts = {}
for item in self.found_items_data:
# item structure: (path, qi, mtime, tags, rating, inode, dev)
tags = item[3]
if tags:
for tag in tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
# Repopulate the filter list
sorted_tags = sorted(list(tag_counts.keys()))
self.tags_list.setRowCount(len(sorted_tags))
for i, tag in enumerate(sorted_tags):
count = tag_counts[tag]
display_text = f"{tag} ({count})"
item = QTableWidgetItem(display_text)
item.setData(Qt.UserRole, tag)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setCheckState(Qt.Checked if tag in checked_tags else Qt.Unchecked)
self.tags_list.setItem(i, 0, item)
item_not = QTableWidgetItem()
item_not.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item_not.setCheckState(Qt.Checked if tag in not_tags else Qt.Unchecked)
self.tags_list.setItem(i, 1, item_not)
self.tags_list.blockSignals(False)
if hasattr(self, 'tag_search_input'):
self.filter_tags_list(self.tag_search_input.text())
def filter_tags_list(self, text):
"""Filters the rows in the tags list table based on the search text."""
search_text = text.strip().lower()
for row in range(self.tags_list.rowCount()):
item = self.tags_list.item(row, 0)
if item:
should_show = search_text in item.text().lower()
self.tags_list.setRowHidden(row, not should_show)
def on_tag_changed(self, item):
"""Handles checkbox changes in the tag filter list."""
# When a checkbox is checked, uncheck the other in the same row to make
# them mutually exclusive (a tag can't be both included and excluded).
if item.checkState() == Qt.Checked:
self.tags_list.blockSignals(True)
row = item.row()
if item.column() == 0: # 'Tag' checkbox
other_item = self.tags_list.item(row, 1) # 'NOT' checkbox
if other_item:
other_item.setCheckState(Qt.Unchecked)
elif item.column() == 1: # 'NOT' checkbox
other_item = self.tags_list.item(row, 0) # 'Tag' checkbox
if other_item:
other_item.setCheckState(Qt.Unchecked)
self.tags_list.blockSignals(False)
self.apply_filters()
def on_selection_changed(self, selected, deselected):
"""Callback to update dock widgets when the thumbnail selection changes."""
if self.tag_edit_widget.isVisible():
self.update_tag_edit_widget()
elif self.info_widget.isVisible():
self.update_info_widget()
def invert_tag_selection(self):
"""Inverts the selection of the 'include' checkboxes in the filter."""
self.tags_list.blockSignals(True)
for i in range(self.tags_list.rowCount()):
item = self.tags_list.item(i, 0)
if item.flags() & Qt.ItemIsUserCheckable:
new_state = Qt.Unchecked \
if item.checkState() == Qt.Checked else Qt.Checked
item.setCheckState(new_state)
self.tags_list.blockSignals(False)
self.apply_filters()
def apply_filters(self):
"""Applies all current name and tag filters to the proxy model."""
# Ensure UI components are initialized
if not hasattr(self, 'tags_list') or \
not hasattr(self, 'filter_mode_group') or \
not self.filter_mode_group.buttons():
return
if self.is_cleaning or self._suppress_updates:
return
# Preserve selection
selected_path = self.get_current_selected_path()
# Gather filter criteria from the UI
include_tags = set()
exclude_tags = set()
for i in range(self.tags_list.rowCount()):
item_tag = self.tags_list.item(i, 0)
item_not = self.tags_list.item(i, 1)
tag_name = item_tag.data(Qt.UserRole)
if item_tag.checkState() == Qt.Checked:
include_tags.add(tag_name)
if item_not.checkState() == Qt.Checked:
exclude_tags.add(tag_name)
# Set filter properties on the proxy model
self.proxy_model.include_tags = include_tags
self.proxy_model.exclude_tags = exclude_tags
name_filter = self.filter_name_input.text().strip().lower()
self.proxy_model.name_filter = name_filter
self.proxy_model.match_mode = "AND" \
if self.filter_mode_group.buttons()[0].isChecked() else "OR"
# Invalidate the model to force a re-filter
self.proxy_model.invalidate()
self._visible_paths_cache = None
# Update UI with filter statistics
visible_count = self.proxy_model.rowCount()
total_count = self.thumbnail_model.rowCount()
hidden_count = total_count - visible_count
if hidden_count > 0:
self.filter_stats_lbl.setText(
UITexts.FILTER_STATS_HIDDEN.format(hidden_count))
self.filter_stats_lbl.show()
else:
self.filter_stats_lbl.hide()
is_filter_active = bool(include_tags or exclude_tags or name_filter)
if is_filter_active:
self.filtered_count_lbl.setText(
UITexts.FILTERED_COUNT.format(visible_count))
else:
self.filtered_count_lbl.setText(UITexts.FILTERED_ZERO)
# Restore selection if it's still visible
if selected_path:
self.find_and_select_path(selected_path)
# Sync open viewers with the new list of visible paths
visible_paths = self.get_visible_image_paths()
self.update_viewers_filter(visible_paths)
def update_viewers_filter(self, visible_paths):
"""Updates all open viewers with the new filtered list of visible paths."""
for w in list(self.viewers):
try:
if not isinstance(w, ImageViewer) or not w.isVisible():
continue
except RuntimeError:
# The widget was deleted, remove it from the list
self.viewers.remove(w)
continue
# Get tags and rating for the current image in this viewer
current_path_in_viewer = w.controller.get_current_path()
viewer_tags = []
viewer_rating = 0
if current_path_in_viewer in self._known_paths:
for item_data in self.found_items_data:
if item_data[0] == current_path_in_viewer:
viewer_tags = item_data[3]
viewer_rating = item_data[4]
break
# Optimization: avoid update if list is identical
current_path = w.controller.get_current_path()
target_list = visible_paths
new_index = -1
if current_path:
try:
new_index = target_list.index(current_path)
except ValueError:
# Current image not in list.
# Check if it was explicitly filtered out (known but hidden) or
# just not loaded yet.
is_filtered = current_path in self._known_paths
if is_filtered and target_list:
# Filtered out: Move to nearest available neighbor
new_index = min(w.controller.index, len(target_list) - 1)
else:
# Not known (loading) or filtered but list empty: Preserve it
target_list = list(visible_paths)
target_list.append(current_path)
new_index = len(target_list) - 1
w.controller.update_list(
target_list, new_index if new_index != -1 else None)
# Pass current image's tags and rating to the controller
w.controller.update_list(
target_list, new_index if new_index != -1 else None,
viewer_tags, viewer_rating)
if not w._is_persistent and not w.controller.image_list:
w.close()
continue
w.populate_filmstrip()
# Reload image if it changed, otherwise just sync selection
if not w._is_persistent and w.controller.get_current_path() != current_path:
w.load_and_fit_image()
else:
w.sync_filmstrip_selection(w.controller.index)
def _setup_viewer_sync(self, viewer):
"""Connects viewer signals to main window slots for selection
synchronization."""
def sync_selection_from_viewer(*args):
"""Synchronize selection from a viewer to the main window.
If the image from the viewer is not found in the main view, it may
be because the main view has been filtered. This function will
attempt to resynchronize the viewer's image list.
"""
path = viewer.controller.get_current_path()
if not path:
return
# First, try to select the image directly. This is the common case
if self.find_and_select_path(path):
return # Success, image was found and selected.
# If not found, it might be because the main view is filtered.
# Attempt to resynchronize the viewer's list with the current view.
# We perform the check inside the lambda to ensure it runs AFTER the sync.
def sync_and_retry():
self.update_viewers_filter(self.get_visible_image_paths())
if not self.find_and_select_path(path):
self.status_lbl.setText(UITexts.IMAGE_NOT_IN_VIEW.format(
os.path.basename(path)))
QTimer.singleShot(0, sync_and_retry)
# This signal is emitted when viewer.controller.index changes
viewer.index_changed.connect(sync_selection_from_viewer)
viewer.activated.connect(sync_selection_from_viewer)
def open_viewer(self, proxy_index, persistent=False):
"""
Opens a new image viewer for the selected item.
Args:
proxy_index (QModelIndex): The index of the item in the proxy model.
persistent (bool): Whether the viewer is part of a persistent layout.
"""
if not proxy_index.isValid() or \
self.proxy_model.data(proxy_index, ITEM_TYPE_ROLE) != 'thumbnail':
return
visible_paths = self.get_visible_image_paths()
# The index of the clicked item in the visible list is NOT its row in the
# proxy model when headers are present. We must find it.
clicked_path = self.proxy_model.data(proxy_index, PATH_ROLE)
try:
new_idx = visible_paths.index(clicked_path)
except ValueError:
return # Should not happen if get_visible_image_paths is correct
if not visible_paths:
return
# Get tags and rating for the current image
current_image_data = self.found_items_data[self.found_items_data.index(
next(item for item in self.found_items_data if item[0] == clicked_path))]
initial_tags = current_image_data[3]
initial_rating = current_image_data[4]
viewer = ImageViewer(self.cache, visible_paths, new_idx,
initial_tags, initial_rating, self, persistent=persistent)
self._setup_viewer_sync(viewer)
self.viewers.append(viewer)
viewer.destroyed.connect(
lambda: self.viewers.remove(viewer) if viewer in self.viewers else None)
viewer.show()
return viewer
def load_full_history(self):
"""Loads the persistent browsing/search history from its JSON file."""
if os.path.exists(HISTORY_PATH):
try:
with open(HISTORY_PATH, 'r') as f:
self.full_history = json.load(f)
except Exception:
self.full_history = []
else:
self.full_history = []
def save_full_history(self):
"""Saves the persistent browsing/search history to its JSON file."""
try:
with open(HISTORY_PATH, 'w') as f:
json.dump(self.full_history, f, indent=4)
except Exception:
pass
def add_to_history(self, term):
"""Adds a new term to both the recent (in-memory) and persistent history."""
# Update recent items in the ComboBox
if term in self.history:
self.history.remove(term)
self.history.insert(0, term)
if len(self.history) > 25:
self.history = self.history[:25]
self.save_config()
# Update the full, persistent history
entry = {
"path": term,
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
# Remove duplicate to bump it to the top
self.full_history = [x for x in self.full_history if x['path'] != term]
self.full_history.insert(0, entry)
self.save_full_history()
if hasattr(self, 'history_tab'):
self.history_tab.refresh_list()
def add_to_mru_tags(self, tag):
"""Adds a tag to the Most Recently Used list."""
if tag in self.mru_tags:
self.mru_tags.remove(tag)
self.mru_tags.appendleft(tag)
self.save_config() # Save on change
def update_metadata_for_path(self, path, metadata=None):
"""Finds an item by path and updates its metadata in the model and internal
data."""
if not path:
return
# Find the item in the source model and update its data
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == path:
# Reload metadata for this item from xattr
try:
if metadata and 'tags' in metadata:
tags = metadata['tags']
else: # Fallback to reading from disk if not provided
raw = XattrManager.get_attribute(path, XATTR_NAME)
tags = sorted(list(set(t.strip() for t in raw.split(',')
if t.strip()))) if raw else []
item.setData(tags, TAGS_ROLE)
except Exception:
item.setData([], TAGS_ROLE)
try:
item.setData(metadata.get('rating', 0)
if metadata else 0, RATING_ROLE)
except Exception:
item.setData(0, RATING_ROLE) # Default to 0 if error
# Notify the view that the data has changed
source_idx = self.thumbnail_model.indexFromItem(item)
self.thumbnail_model.dataChanged.emit(
source_idx, source_idx, [TAGS_ROLE, RATING_ROLE])
# Update internal data structure to prevent stale data on rebuild
current_tags = item.data(TAGS_ROLE)
current_rating = item.data(RATING_ROLE)
self._update_internal_data(path, tags=current_tags,
rating=current_rating)
# Update proxy filter cache to prevent stale filtering
self.proxy_model.add_to_cache(path, current_tags)
break
if self.main_dock.isVisible():
self.on_tags_tab_changed(self.tags_tabs.currentIndex())
# Re-apply filters in case the tag change affects visibility
self.apply_filters()
def on_view_mode_changed(self, index):
"""Callback for when the view mode (Flat/Folder) changes."""
self._suppress_updates = True
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)
self.proxy_model.collapsed_groups.clear()
self._group_info_cache.clear()
is_grouped = index > 0
# Disable uniform item sizes in grouped modes to allow headers of different
# height
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))
self.rebuild_view(full_reset=True)
self.update_tag_list()
self.save_config()
self.setFocus()
def on_sort_changed(self):
"""Callback for when the sort order dropdown changes."""
self.rebuild_view(full_reset=True)
self.save_config()
if hasattr(self, 'history_tab'):
self.history_tab.refresh_list()
self.setFocus()
def _get_tier_for_size(self, requested_size):
"""Determines the ideal thumbnail tier based on the requested size."""
if requested_size < 192:
return 128
if requested_size < 320:
return 256
return 512
def on_slider_changed(self, v):
"""Callback for when the thumbnail size slider changes."""
self.current_thumb_size = v
self.size_label.setText(f"{v}px")
new_tier = self._get_tier_for_size(v)
# If the required tier for the new size is different, we trigger generation.
if new_tier != self._current_thumb_tier:
self._current_thumb_tier = new_tier
# 1. Update the list of sizes for the main scanner to generate for
# any NEW images (e.g., from scrolling down). It will now only
# generate the tier needed for the current view.
constants.SCANNER_GENERATE_SIZES = [new_tier]
# 2. For all images ALREADY loaded, start a background job to
# generate the newly required thumbnail size. This is interruptible.
self.generate_missing_thumbnails(new_tier)
# Update the delegate's size hint and the view's grid size
new_hint = self.delegate.sizeHint(None, None)
is_grouped = (self.proxy_model.group_by_folder or
self.proxy_model.group_by_day or
self.proxy_model.group_by_week or
self.proxy_model.group_by_month or
self.proxy_model.group_by_year or
self.proxy_model.group_by_rating)
if is_grouped:
self.thumbnail_view.setGridSize(QSize())
self.thumbnail_view.doItemsLayout()
else:
self.thumbnail_view.setGridSize(new_hint)
self.thumbnail_view.update()
self.setFocus()
def generate_missing_thumbnails(self, size):
"""
Starts a background thread to generate thumbnails of a specific size for all
currently loaded images.
"""
if self.thumbnail_generator and self.thumbnail_generator.isRunning():
self.thumbnail_generator.stop()
paths = self.get_all_image_paths()
if not paths:
return
self.thumbnail_generator = ThumbnailGenerator(self.cache, paths, size)
self.thumbnail_generator.generation_complete.connect(
self.on_high_res_generation_finished)
self.thumbnail_generator.progress.connect(
lambda p, t: self.status_lbl.setText(
f"Generating {size}px thumbnails: {p}/{t}")
)
self.thumbnail_generator.start()
def on_high_res_generation_finished(self):
"""Slot called when the background thumbnail generation is complete."""
self.status_lbl.setText(UITexts.HIGH_RES_GENERATED)
self.thumbnail_view.viewport().update()
def refresh_content(self):
"""Refreshes the current view by re-running the last search or scan."""
if not self.history:
return
current_selection = self.get_current_selected_path()
term = self.history[0]
if term.startswith("file:/"):
path = term[6:]
if os.path.isfile(path):
self.start_scan([os.path.dirname(path)], select_path=current_selection)
return
self.process_term(term, select_path=current_selection)
def process_term(self, term, select_path=None):
"""Processes a search term, file path, or layout directive."""
self.add_to_history(term)
self.update_search_input()
if term.startswith("layout:/"):
if not self.is_xcb:
return
# Handle loading a layout
filename = os.path.join(LAYOUTS_DIR, f"{term[8:]}")
base, ext = os.path.splitext(filename)
if ext != ".layout":
filename = filename + ".layout"
if os.path.exists(filename):
self.restore_layout(filename)
else:
self.is_cleaning = True
if self.scanner:
self.scanner.stop()
self.scanner.wait()
QMessageBox.critical(self,
UITexts.ERROR_LOADING_LAYOUT_TITLE.format(
PROG_NAME),
UITexts.ERROR_LOADING_LAYOUT_TEXT.format(term[8:]))
QApplication.quit()
else:
# Handle a file path or search query
if term.startswith("search:/"):
path = term[8:]
else:
path = term[6:] if term.startswith("file:/") else term
if os.path.isfile(path):
# If a single file is passed, open it in a viewer and scan its directory
self.thumbnail_model.clear()
self.active_viewer = ImageViewer(self.cache, [path], 0,
initial_tags=None,
initial_rating=0, parent=self,
persistent=True)
self.start_scan([os.path.dirname(path)],
active_viewer=self.active_viewer)
self._setup_viewer_sync(self.active_viewer)
self.viewers.append(self.active_viewer)
self.active_viewer.destroyed.connect(
lambda obj=self.active_viewer: self.viewers.remove(obj)
if obj in self.viewers else None)
self.active_viewer.show()
else:
# If a directory or search term, start a scan
self.start_scan([path], select_path=select_path)
def update_search_input(self):
"""Updates the search input combo box with history items and icons."""
self.search_input.clear()
for h in self.history:
icon = QIcon.fromTheme("system-search")
text = h.replace("search:/", "").replace("file:/", "")
if h.startswith("file:/"):
path = h[6:]
if os.path.isdir(os.path.expanduser(path)):
icon = QIcon.fromTheme("folder")
else:
icon = QIcon.fromTheme("image-x-generic")
elif h.startswith("layout:/"):
icon = QIcon.fromTheme("view-grid")
elif h.startswith("search:/"):
icon = QIcon.fromTheme("system-search")
self.search_input.addItem(icon, text)
def on_search_triggered(self):
"""Callback for when a search is triggered from the input box."""
t = self.search_input.currentText().strip()
if t:
# Detect if the term is an existing path or a search query
self.process_term(f"file:/{t}" if os.path.exists(
os.path.expanduser(t)) else f"search:/{t}")
def select_directory(self):
"""Opens a file dialog to select an image or directory."""
dialog = QFileDialog(self)
dialog.setWindowTitle(UITexts.SELECT_IMAGE_TITLE)
default_folder = os.path.expanduser("~")
dialog.setDirectory(default_folder)
dialog.setFileMode(QFileDialog.ExistingFile)
# Don't force.
# dialog.setOption(QFileDialog.Option.DontUseNativeDialog, False)
dialog.setNameFilters([IMAGE_MIME_TYPES])
if self.scanner and self.scanner._is_running:
self.scanner.stop()
self.scanner.wait()
if dialog.exec():
selected = dialog.selectedFiles()
if selected:
# Process the first selected file
self.process_term(f"file:/{selected[0]}")
def load_config(self):
"""Loads application settings from the JSON configuration file."""
d = {}
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, 'r') as f:
d = json.load(f)
except Exception:
pass # Ignore errors in config file
self.history = d.get("history", [])
self.current_thumb_size = d.get("thumb_size",
THUMBNAILS_DEFAULT_SIZE)
self.slider.setValue(self.current_thumb_size)
self.size_label.setText(f"{self.current_thumb_size}px")
self.sort_combo.setCurrentIndex(d.get("sort_order", 0))
self.view_mode_combo.setCurrentIndex(d.get("view_mode", 0))
self.show_viewer_status_bar = d.get("show_viewer_status_bar", True)
self.filmstrip_position = d.get("filmstrip_position", "bottom")
self.show_filmstrip = d.get("show_filmstrip", False)
self.show_faces = d.get("show_faces", False)
if "active_dock_tab" in d:
self.tags_tabs.setCurrentIndex(d["active_dock_tab"])
self.face_names_history = d.get("face_names_history", [])
self.pet_names_history = d.get("pet_names_history", [])
self.object_names_history = d.get("object_names_history", [])
self.landmark_names_history = d.get("landmark_names_history", [])
max_tags = APP_CONFIG.get("tags_menu_max_items", TAGS_MENU_MAX_ITEMS_DEFAULT)
self.mru_tags = deque(d.get("mru_tags", []),
maxlen=max_tags)
self._load_shortcuts_config(d)
# Restore window geometry and state
if "geometry" in d:
g = d["geometry"]
self.setGeometry(g["x"], g["y"], g["w"], g["h"])
if "window_state" in d:
self.restoreState(
QByteArray.fromBase64(d["window_state"].encode()))
def _load_shortcuts_config(self, config_dict):
"""Loads global and viewer shortcuts from the configuration dictionary."""
# Load global shortcuts
self.loaded_global_shortcuts = config_dict.get("global_shortcuts")
# Load viewer shortcuts
# 1. Load defaults into a temporary dict.
default_shortcuts = {}
for action, (key, mods) in DEFAULT_VIEWER_SHORTCUTS.items():
if action in VIEWER_ACTIONS:
desc, _ = VIEWER_ACTIONS[action]
default_shortcuts[(int(key),
Qt.KeyboardModifiers(mods))] = (action, desc)
# 2. Load user's config if it exists.
v_shortcuts = config_dict.get("viewer_shortcuts", [])
if v_shortcuts:
user_shortcuts = {
(k, Qt.KeyboardModifiers(m)): (act, desc)
for (k, m), (act, desc) in v_shortcuts
}
# 3. Merge: Start with user's config, then add missing defaults.
user_actions = {val[0] for val in user_shortcuts.values()}
user_keys = set(user_shortcuts.keys())
for key, (action, desc) in default_shortcuts.items():
if action not in user_actions and key not in user_keys:
user_shortcuts[key] = (action, desc)
self.viewer_shortcuts = user_shortcuts
else:
# No user config for viewer shortcuts, just use the defaults.
self.viewer_shortcuts = default_shortcuts
def save_config(self):
"""Saves application settings to the JSON configuration file."""
# Update the global APP_CONFIG with the current state of the MainWindow
APP_CONFIG["history"] = self.history
APP_CONFIG["thumb_size"] = self.current_thumb_size
APP_CONFIG["sort_order"] = self.sort_combo.currentIndex()
APP_CONFIG["view_mode"] = self.view_mode_combo.currentIndex()
APP_CONFIG["show_viewer_status_bar"] = self.show_viewer_status_bar
APP_CONFIG["filmstrip_position"] = self.filmstrip_position
APP_CONFIG["show_filmstrip"] = self.show_filmstrip
APP_CONFIG["show_faces"] = self.show_faces
APP_CONFIG["window_state"] = self.saveState().toBase64().data().decode()
APP_CONFIG["active_dock_tab"] = self.tags_tabs.currentIndex()
APP_CONFIG["face_names_history"] = self.face_names_history
APP_CONFIG["pet_names_history"] = self.pet_names_history
APP_CONFIG["object_names_history"] = self.object_names_history
APP_CONFIG["landmark_names_history"] = self.landmark_names_history
APP_CONFIG["mru_tags"] = list(self.mru_tags)
# Save viewer shortcuts as list for JSON serialization
v_shortcuts_list = []
for (k, m), (act, desc) in self.viewer_shortcuts.items():
try:
mod_int = int(m)
except TypeError:
mod_int = m.value
v_shortcuts_list.append([[k, mod_int], [act, desc]])
APP_CONFIG["viewer_shortcuts"] = v_shortcuts_list
# Save global shortcuts
if hasattr(self, 'shortcut_controller') and self.shortcut_controller:
g_shortcuts_list = []
for (k, m), (act, ignore, desc, cat) in \
self.shortcut_controller._shortcuts.items():
try:
mod_int = int(m)
except TypeError:
mod_int = m.value
g_shortcuts_list.append([[k, mod_int], [act, ignore, desc, cat]])
APP_CONFIG["global_shortcuts"] = g_shortcuts_list
# Save geometry only if the window is visible
if self.isVisible():
APP_CONFIG["geometry"] = {"x": self.x(), "y": self.y(),
"w": self.width(), "h": self.height()}
constants.save_app_config()
def resizeEvent(self, e):
"""Handles window resize events to trigger a debounced grid refresh."""
super().resizeEvent(e)
self.thumbnails_refresh_timer.start()
def eventFilter(self, source, event):
"""Filters events from child widgets, like viewport resize."""
if source is self.thumbnail_view.viewport() and event.type() == QEvent.Resize:
self.thumbnails_refresh_timer.start()
return super().eventFilter(source, event)
def open_current_folder(self):
"""Opens the directory of the selected image in the default file manager."""
path = self.get_current_selected_path()
if path:
QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path)))
def handle_initial_args(self, args):
"""Handles command-line arguments passed to the application at startup."""
path = " ".join(args).strip()
full_path = os.path.abspath(os.path.expanduser(path))
if os.path.isfile(full_path):
self.add_to_history(f"file:/{full_path}")
# Refresh combo box with history
self.update_search_input() # This is a disk read.
# Open viewer directly
self.active_viewer = ImageViewer(self.cache, [full_path], 0,
initial_tags=None, initial_rating=0,
parent=self, persistent=True)
self._setup_viewer_sync(self.active_viewer)
self.viewers.append(self.active_viewer)
self.active_viewer.destroyed.connect(
lambda obj=self.active_viewer: self.viewers.remove(obj)
if obj in self.viewers else None)
self.active_viewer.show()
self.hide() # Main window is hidden in direct view mode
# Scan the file's directory in the background for context
self._scan_all = False
self.start_scan([full_path, str(Path(full_path).parent)],
sync_viewer=True, active_viewer=self.active_viewer)
else:
# If not a file, process as a generic term (path, search, or layout)
term = path if path.startswith(("search:/", "file:/", "layout:/")) \
else f"file:/{path}"
self.process_term(term)
def set_app_icon(self):
"""Sets the application icon from the current theme."""
icon = QIcon.fromTheme(ICON_THEME, QIcon.fromTheme(ICON_THEME_FALLBACK))
self.setWindowIcon(icon)
# --- Context Menu ---
def show_context_menu(self, pos):
"""Shows the context menu for the thumbnail view."""
menu = QMenu(self)
# Check if clicked on a header (which isn't usually selectable)
index_at_pos = self.thumbnail_view.indexAt(pos)
if index_at_pos.isValid() and \
self.proxy_model.data(index_at_pos, ITEM_TYPE_ROLE) == 'header':
group_name = self.proxy_model.data(index_at_pos, GROUP_NAME_ROLE)
if group_name:
action_toggle = menu.addAction("Collapse/Expand Group")
action_toggle.triggered.connect(
lambda: self.toggle_group_collapse(group_name))
menu.exec(self.thumbnail_view.mapToGlobal(pos))
return
menu.setStyleSheet("QMenu { border: 1px solid #555; }")
selected_indexes = self.thumbnail_view.selectedIndexes()
if not selected_indexes:
return
def add_action_with_shortcut(target_menu, text, icon_name, action_name, slot):
shortcut_str = ""
if action_name and hasattr(self, 'shortcut_controller'):
shortcut_map = self.shortcut_controller.action_to_shortcut
if action_name in shortcut_map:
key, mods = shortcut_map[action_name]
try:
mod_val = int(mods)
except TypeError:
mod_val = mods.value
seq = QKeySequence(mod_val | key)
shortcut_str = seq.toString(QKeySequence.NativeText)
display_text = f"{text}\t{shortcut_str}" if shortcut_str else text
action = target_menu.addAction(QIcon.fromTheme(icon_name), display_text)
action.triggered.connect(slot)
return action
action_view = menu.addAction(QIcon.fromTheme("image-x-generic"),
UITexts.CONTEXT_MENU_VIEW)
action_view.triggered.connect(lambda: self.open_viewer(selected_indexes[0]))
menu.addSeparator()
selection_menu = menu.addMenu(QIcon.fromTheme("edit-select"), UITexts.SELECT)
add_action_with_shortcut(selection_menu, UITexts.CONTEXT_MENU_SELECT_ALL,
"edit-select-all", "select_all",
self.select_all_thumbnails)
add_action_with_shortcut(selection_menu, UITexts.CONTEXT_MENU_SELECT_NONE,
"edit-clear", "select_none",
self.select_none_thumbnails)
add_action_with_shortcut(selection_menu,
UITexts.CONTEXT_MENU_INVERT_SELECTION,
"edit-select-invert", "invert_selection",
self.invert_selection_thumbnails)
menu.addSeparator()
open_submenu = menu.addMenu(QIcon.fromTheme("document-open"),
UITexts.CONTEXT_MENU_OPEN)
full_path = os.path.abspath(
self.proxy_model.data(selected_indexes[0], PATH_ROLE))
self.populate_open_with_submenu(open_submenu, full_path)
path = self.proxy_model.data(selected_indexes[0], PATH_ROLE)
action_open_location = menu.addAction(QIcon.fromTheme("folder-search"),
UITexts.CONTEXT_MENU_OPEN_SEARCH_LOCATION)
action_open_location.triggered.connect(
lambda: self.process_term(f"file:/{os.path.dirname(path)}"))
action_open_default_app = menu.addAction(
QIcon.fromTheme("system-run"),
UITexts.CONTEXT_MENU_OPEN_DEFAULT_APP)
action_open_default_app.triggered.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(path))))
menu.addSeparator()
add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_RENAME, "edit-rename",
"rename_image",
lambda: self.rename_image(selected_indexes[0].row()))
action_move = menu.addAction(QIcon.fromTheme("edit-move"),
UITexts.CONTEXT_MENU_MOVE_TO)
action_move.triggered.connect(self.move_current_image)
action_copy = menu.addAction(QIcon.fromTheme("edit-copy"),
UITexts.CONTEXT_MENU_COPY_TO)
action_copy.triggered.connect(self.copy_current_image)
menu.addSeparator()
rotate_menu = menu.addMenu(QIcon.fromTheme("transform-rotate"),
UITexts.CONTEXT_MENU_ROTATE)
action_rotate_ccw = rotate_menu.addAction(QIcon.fromTheme("object-rotate-left"),
UITexts.CONTEXT_MENU_ROTATE_LEFT)
action_rotate_ccw.triggered.connect(lambda: self.rotate_current_image(-90))
action_rotate_cw = rotate_menu.addAction(QIcon.fromTheme("object-rotate-right"),
UITexts.CONTEXT_MENU_ROTATE_RIGHT)
action_rotate_cw.triggered.connect(lambda: self.rotate_current_image(90))
menu.addSeparator()
add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_TRASH, "user-trash",
"move_to_trash",
lambda: self.delete_current_image(permanent=False))
add_action_with_shortcut(menu, UITexts.CONTEXT_MENU_DELETE, "edit-delete",
"delete_permanently",
lambda: self.delete_current_image(permanent=True))
menu.addSeparator()
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_dir = clipboard_menu.addAction(QIcon.fromTheme("folder"),
UITexts.CONTEXT_MENU_COPY_DIR)
action_copy_dir.triggered.connect(self.copy_dir_path)
menu.addSeparator()
action_regenerate_thumbnail = menu.addAction(UITexts.CONTEXT_MENU_REGENERATE)
action_regenerate_thumbnail.triggered.connect(
lambda: self.regenerate_thumbnail(path))
menu.addSeparator()
action_props = menu.addAction(QIcon.fromTheme("document-properties"),
UITexts.CONTEXT_MENU_PROPERTIES)
action_props.triggered.connect(self.show_properties)
menu.exec(self.thumbnail_view.mapToGlobal(pos))
def toggle_group_collapse(self, group_name):
"""Toggles the collapsed state of a group."""
if group_name in self.proxy_model.collapsed_groups:
self.proxy_model.collapsed_groups.remove(group_name)
else:
self.proxy_model.collapsed_groups.add(group_name)
self.proxy_model.invalidate()
self._visible_paths_cache = None
def regenerate_thumbnail(self, path):
"""Regenerates the thumbnail for the specified path."""
if not path:
return
# Create a ThumbnailGenerator to regenerate the thumbnail
size = self._get_tier_for_size(self.current_thumb_size)
self.thumbnail_generator = ThumbnailGenerator(self.cache, [path], size)
self.thumbnail_generator.generation_complete.connect(
self.on_high_res_generation_finished)
self.thumbnail_generator.progress.connect(
lambda p, t: self.status_lbl.setText(
f"Regenerating thumbnail: {p}/{t}")
)
self.thumbnail_generator.start()
# Invalidate the cache so the new thumbnail is loaded
self.cache.invalidate_path(path)
self.rebuild_view()
def get_app_info(self, desktop_file_id):
"""Gets the readable name and icon of an application from its .desktop file."""
if desktop_file_id in self._app_info_cache:
return self._app_info_cache[desktop_file_id]
desktop_file_id = desktop_file_id.split(':')[-1].strip()
desktop_path = desktop_file_id
if not desktop_path.startswith("/"):
# Search in standard application paths including flatpak/snap/local
search_paths = [
"/usr/share/applications",
os.path.expanduser("~/.local/share/applications"),
"/usr/local/share/applications",
"/var/lib/flatpak/exports/share/applications",
"/var/lib/snapd/desktop/applications"
]
if "XDG_DATA_DIRS" in os.environ:
for path in os.environ["XDG_DATA_DIRS"].split(":"):
if path:
app_path = os.path.join(path, "applications")
if app_path not in search_paths:
search_paths.append(app_path)
for path in search_paths:
full_p = os.path.join(path, desktop_file_id)
if os.path.exists(full_p):
desktop_path = full_p
break
name = ""
icon = ""
try:
if os.path.exists(desktop_path):
name = subprocess.check_output(
["kreadconfig6", "--file", desktop_path,
"--group", "Desktop Entry", "--key", "Name"],
text=True
).strip()
icon = subprocess.check_output(
["kreadconfig6", "--file", desktop_path,
"--group", "Desktop Entry", "--key", "Icon"],
text=True
).strip()
except Exception:
pass
if not name:
name = os.path.basename(
desktop_file_id).replace(".desktop", "").capitalize()
result = (name, icon, desktop_path)
self._app_info_cache[desktop_file_id] = result
return result
def populate_open_with_submenu(self, menu, full_path):
"""Populates the 'Open With' submenu with associated applications."""
if not full_path:
return
try:
# 1. Get the mimetype of the file
mime_query = subprocess.check_output(["kmimetypefinder", full_path],
text=True).strip()
if mime_query in self._open_with_cache:
app_entries = self._open_with_cache[mime_query]
else:
# 2. Query for associated applications using 'gio mime'
apps_cmd = ["gio", "mime", mime_query]
output = subprocess.check_output(apps_cmd, text=True).splitlines()
app_entries = []
seen_resolved_paths = set() # For deduplication based on resolved path
for line in output:
line = line.strip()
if ":" not in line and line.endswith(".desktop"):
app_name, icon_name, resolved_path = self.get_app_info(line)
if resolved_path in seen_resolved_paths:
continue
seen_resolved_paths.add(resolved_path)
# Store original line for gtk-launch
app_entries.append((app_name, icon_name, line))
self._open_with_cache[mime_query] = app_entries
if not app_entries:
menu.addAction(UITexts.CONTEXT_MENU_NO_APPS_FOUND).setEnabled(False)
else:
for app_name, icon_name, desktop_file_id_from_gio_mime in app_entries:
icon = QIcon.fromTheme(icon_name) if icon_name else QIcon()
action = menu.addAction(icon, app_name)
action.triggered.connect(
lambda checked=False, df=desktop_file_id_from_gio_mime:
subprocess.Popen(["gtk-launch", df, full_path]))
menu.addSeparator()
action_other = menu.addAction(QIcon.fromTheme("applications-other"),
"Open with other application...")
action_other.triggered.connect(
lambda: self.open_with_system_chooser(full_path))
except Exception:
action = menu.addAction(UITexts.CONTEXT_MENU_ERROR_LISTING_APPS)
action.setEnabled(False)
def open_with_system_chooser(self, path):
"""Opens the system application chooser using xdg-desktop-portal."""
if not path:
return
# Use QDBusMessage directly to avoid binding issues with
# QDBusInterface.asyncCall
msg = QDBusMessage.createMethodCall(
"org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.portal.OpenURI",
"OpenURI"
)
# Arguments: parent_window (str), uri (str), options (dict/a{sv})
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."""
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)
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:
return
QApplication.clipboard().setText(os.path.dirname(path))
def show_properties(self):
"""Shows the custom properties dialog for the selected file."""
full_path = self.get_current_selected_path()
if not full_path:
return
full_path = os.path.abspath(full_path)
# Extract metadata from selected item
tags = []
rating = 0
selected_indexes = self.thumbnail_view.selectedIndexes()
if selected_indexes:
idx = selected_indexes[0]
tags = self.proxy_model.data(idx, TAGS_ROLE)
rating = self.proxy_model.data(idx, RATING_ROLE) or 0
dlg = PropertiesDialog(
full_path, initial_tags=tags, initial_rating=rating, parent=self)
dlg.exec()
def clear_thumbnail_cache(self):
"""Clears the entire in-memory and on-disk thumbnail cache."""
confirm = QMessageBox(self)
confirm.setIcon(QMessageBox.Warning)
confirm.setWindowTitle(UITexts.CONFIRM_CLEAR_CACHE_TITLE)
confirm.setText(UITexts.CONFIRM_CLEAR_CACHE_TEXT)
confirm.setInformativeText(UITexts.CONFIRM_CLEAR_CACHE_INFO)
confirm.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
confirm.setDefaultButton(QMessageBox.No)
if confirm.exec() != QMessageBox.Yes:
return
self.cache.clear_cache()
self.status_lbl.setText(UITexts.CACHE_CLEARED)
def propagate_rename(self, old_path, new_path, source_viewer=None):
"""Propagates a file rename across the application."""
self._visible_paths_cache = None
# Update found_items_data to ensure consistency on future rebuilds
current_tags = None
for i, item_data in enumerate(self.found_items_data):
if item_data[0] == old_path:
# tuple structure: (path, qi, mtime, tags, rating, inode, dev)
self.found_items_data[i] = (new_path,) + item_data[1:]
current_tags = item_data[3]
self._known_paths.discard(old_path)
self._known_paths.add(new_path)
# Clean up group cache since the key (path) has changed
cache_key = (old_path, item_data[2], item_data[4])
if cache_key in self._group_info_cache:
del self._group_info_cache[cache_key]
break
# Update proxy model cache to avoid stale entries
if old_path in self.proxy_model._data_cache:
del self.proxy_model._data_cache[old_path]
if current_tags is not None:
self.proxy_model._data_cache[new_path] = (
set(current_tags) if current_tags else set(),
os.path.basename(new_path).lower())
# Update the main model
for row in range(self.thumbnail_model.rowCount()):
item = self.thumbnail_model.item(row)
if item and item.data(PATH_ROLE) == old_path:
item.setData(new_path, PATH_ROLE)
item.setText(os.path.basename(new_path))
# No need to update the icon, it's the same image data
source_index = self.thumbnail_model.indexFromItem(item)
self.thumbnail_model.dataChanged.emit(source_index, source_index)
break
# Update the cache entry
self.cache.rename_entry(old_path, new_path)
# Update other open viewers
for v in self.viewers:
if v is not source_viewer and isinstance(v, ImageViewer) and v.isVisible():
if old_path in v.controller.image_list:
try:
idx = v.controller.image_list.index(old_path)
v.controller.image_list[idx] = new_path
if v.controller.index == idx:
v.update_view(resize_win=False)
v.populate_filmstrip()
except ValueError:
pass
def rename_image(self, proxy_row_index):
"""Handles the logic for renaming a file from the main thumbnail view."""
proxy_index = self.proxy_model.index(proxy_row_index, 0)
if not proxy_index.isValid():
return
while True:
old_path = self.proxy_model.data(proxy_index, PATH_ROLE)
if not old_path:
return
old_dir = os.path.dirname(old_path)
old_filename = os.path.basename(old_path)
base_name, extension = os.path.splitext(old_filename)
new_base, ok = QInputDialog.getText(
self, UITexts.RENAME_FILE_TITLE,
UITexts.RENAME_FILE_TEXT.format(old_filename),
QLineEdit.Normal, base_name
)
if ok and new_base and new_base != base_name:
# Re-add extension if the user omitted it
new_base_name, new_extension = os.path.splitext(new_base)
if new_extension == extension:
new_filename = new_base
else:
new_filename = new_base_name + extension
new_path = os.path.join(old_dir, new_filename)
if os.path.exists(new_path):
QMessageBox.warning(self,
UITexts.RENAME_ERROR_TITLE,
UITexts.RENAME_ERROR_EXISTS.format(
new_filename))
# Loop again to ask for a different name
else:
try:
os.rename(old_path, new_path)
self.propagate_rename(old_path, new_path)
self.status_lbl.setText(
UITexts.FILE_RENAMED.format(new_filename))
break
except Exception as e:
QMessageBox.critical(self,
UITexts.SYSTEM_ERROR,
UITexts.ERROR_RENAME.format(str(e)))
break
else:
break
def select_all_thumbnails(self):
"""Selects all visible items in the thumbnail view."""
if not self.thumbnail_view.isVisible() or self.proxy_model.rowCount() == 0:
return
selection_model = self.thumbnail_view.selectionModel()
# Create a selection that covers all rows in the proxy model
top_left = self.proxy_model.index(0, 0)
bottom_right = self.proxy_model.index(self.proxy_model.rowCount() - 1, 0)
selection = QItemSelection(top_left, bottom_right)
selection_model.select(selection, QItemSelectionModel.Select)
def select_none_thumbnails(self):
"""Clears the selection in the thumbnail view."""
if not self.thumbnail_view.isVisible():
return
self.thumbnail_view.selectionModel().clearSelection()
def invert_selection_thumbnails(self):
"""Inverts the current selection of visible items."""
if not self.thumbnail_view.isVisible() or self.proxy_model.rowCount() == 0:
return
selection_model = self.thumbnail_view.selectionModel()
# Get all selectable items
all_items_selection = QItemSelection()
for row in range(self.proxy_model.rowCount()):
index = self.proxy_model.index(row, 0)
if self.proxy_model.data(index, ITEM_TYPE_ROLE) == 'thumbnail':
all_items_selection.select(index, index)
# Invert the current selection against all selectable items
selection_model.select(all_items_selection, QItemSelectionModel.Toggle)
def update_load_all_button_state(self):
"""Updates the text and tooltip of the 'load all' button based on its state."""
if self._is_loading_all:
self.btn_load_all.setText("X")
self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP_ALT)
else:
self.btn_load_all.setText("+a")
self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP)
def _create_language_menu(self):
"""Creates the language selection menu and adds it to the menubar."""
# Assuming you have a settings or view menu. Add it where you see fit.
# If you don't have one, you can add it directly to the menu bar.
settings_menu = self.menuBar().addMenu("&Settings") # Or get an existing menu
language_menu = settings_menu.addMenu(UITexts.MENU_LANGUAGE)
lang_group = QActionGroup(self)
lang_group.setExclusive(True)
lang_group.triggered.connect(self._on_language_changed)
for code, name in SUPPORTED_LANGUAGES.items():
action = QAction(name, self, checkable=True)
action.setData(code)
if code == CURRENT_LANGUAGE:
action.setChecked(True)
language_menu.addAction(action)
lang_group.addAction(action)
def _on_language_changed(self, action):
"""Handles language change, saves config, and prompts for restart."""
new_lang = action.data()
# Only save and show message if the language actually changed
if new_lang != APP_CONFIG.get("language", CURRENT_LANGUAGE):
APP_CONFIG["language"] = new_lang
constants.save_app_config()
# Inform user that a restart is needed for the change to take effect
msg_box = QMessageBox(self)
msg_box.setWindowTitle(UITexts.RESTART_REQUIRED_TITLE)
msg_box.setText(UITexts.RESTART_REQUIRED_TEXT.format(
language=action.text()))
msg_box.setIcon(QMessageBox.Information)
msg_box.setStandardButtons(QMessageBox.Ok)
msg_box.exec()
def main():
"""The main entry point for the Bagheera Image Viewer application."""
app = QApplication(sys.argv)
# Increase QPixmapCache limit (default is usually small, ~10MB) to ~100MB
QPixmapCache.setCacheLimit(102400)
cache = ThumbnailCache()
args = [a for a in sys.argv[1:] if a != "--x11"]
if args:
path = " ".join(args).strip()
if path.startswith("file:/"):
path = path[6:]
win = MainWindow(cache, args)
shortcut_controller = AppShortcutController(win)
win.shortcut_controller = shortcut_controller
app.installEventFilter(shortcut_controller)
sys.exit(app.exec())
if __name__ == "__main__":
main()