Better status bar messages

This commit is contained in:
Ignacio Serantes
2026-04-07 09:17:08 +02:00
parent 3e374a5871
commit 9685c01760
5 changed files with 140 additions and 41 deletions

View File

@@ -14,7 +14,7 @@ Classes:
MainWindow: The main application window containing the thumbnail grid and docks. MainWindow: The main application window containing the thumbnail grid and docks.
""" """
__appname__ = "BagheeraView" __appname__ = "BagheeraView"
__version__ = "0.9.17" __version__ = "0.9.18"
__author__ = "Ignacio Serantes" __author__ = "Ignacio Serantes"
__email__ = "kde@aynoa.net" __email__ = "kde@aynoa.net"
__license__ = "LGPL" __license__ = "LGPL"
@@ -1103,6 +1103,23 @@ class MainWindow(QMainWindow):
# Bottom bar with status and controls # Bottom bar with status and controls
bot = QHBoxLayout() bot = QHBoxLayout()
self.btn_load_all = QPushButton()
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()
self.btn_load_all.hide()
bot.addWidget(self.btn_load_all)
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)
self.btn_load_more.hide()
bot.addWidget(self.btn_load_more)
self.btn_cancel_duplicates = QPushButton() self.btn_cancel_duplicates = QPushButton()
self.btn_cancel_duplicates.setIcon(QIcon.fromTheme("process-stop")) self.btn_cancel_duplicates.setIcon(QIcon.fromTheme("process-stop"))
self.btn_cancel_duplicates.setFixedSize(22, 22) self.btn_cancel_duplicates.setFixedSize(22, 22)
@@ -1133,20 +1150,6 @@ class MainWindow(QMainWindow):
self.hide_progress_timer.setSingleShot(True) self.hide_progress_timer.setSingleShot(True)
self.hide_progress_timer.timeout.connect(self.progress_bar.hide) 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() bot.addStretch()
self.filtered_count_lbl = QLabel(UITexts.FILTERED_ZERO) self.filtered_count_lbl = QLabel(UITexts.FILTERED_ZERO)
@@ -2798,7 +2801,6 @@ class MainWindow(QMainWindow):
self._scanner_total_files = count self._scanner_total_files = count
self._is_loading = False self._is_loading = False
has_more = i < count has_more = i < count
self.btn_load_more.setVisible(has_more)
self.btn_load_all.setVisible(has_more) self.btn_load_all.setVisible(has_more)
def request_more_images(self, amount): def request_more_images(self, amount):
@@ -5094,10 +5096,10 @@ class MainWindow(QMainWindow):
def update_load_all_button_state(self): def update_load_all_button_state(self):
"""Updates the text and tooltip of the 'load all' button based on its state.""" """Updates the text and tooltip of the 'load all' button based on its state."""
if self._is_loading_all: if self._is_loading_all:
self.btn_load_all.setText("X") self.btn_load_all.setIcon(QIcon.fromTheme("process-stop"))
self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP_ALT) self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP_ALT)
else: else:
self.btn_load_all.setText("+a") self.btn_load_all.setIcon(QIcon.fromTheme("media-playback-start"))
self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP) self.btn_load_all.setToolTip(UITexts.LOAD_ALL_TOOLTIP)
def _create_language_menu(self): def _create_language_menu(self):

View File

@@ -29,7 +29,7 @@ if FORCE_X11:
# --- CONFIGURATION --- # --- CONFIGURATION ---
PROG_NAME = "Bagheera Image Viewer" PROG_NAME = "Bagheera Image Viewer"
PROG_ID = "bagheeraview" PROG_ID = "bagheeraview"
PROG_VERSION = "0.9.17" PROG_VERSION = "0.9.18-dev"
PROG_AUTHOR = "Ignacio Serantes" PROG_AUTHOR = "Ignacio Serantes"
# --- CACHE SETTINGS --- # --- CACHE SETTINGS ---

View File

@@ -389,6 +389,45 @@ class DuplicateCache(QObject):
txn.delete(key, db=self._pending_db) txn.delete(key, db=self._pending_db)
return True return True
def mark_as_pending_batch(self, pairs_data):
"""
Marks multiple pairs as pending review in a single transaction.
pairs_data: list of (path1, path2, similarity, timestamp)
"""
if not self._lmdb_env or self._pending_db is None or not pairs_data:
return False
with QMutexLocker(self._db_lock):
with self._lmdb_env.begin(write=True) as txn:
for p1, p2, similarity, timestamp in pairs_data:
key = self._get_pair_lmdb_key(p1, p2)
if not key:
continue
ts = timestamp if timestamp is not None else int(time.time())
sim_str = str(similarity) if similarity is not None else ""
val_str = f"{p1}|{p2}|{sim_str}|{ts}"
value = val_str.encode('utf-8')
txn.put(key, value, db=self._pending_db)
return True
def get_all_exceptions_set(self):
"""Returns a set of canonical pairs (frozenset) marked as exceptions."""
exceptions = set()
if not self._lmdb_env or self._exceptions_db is None:
return exceptions
with QMutexLocker(self._db_lock):
with self._lmdb_env.begin(write=False) as txn:
cursor = txn.cursor(db=self._exceptions_db)
for _, value_bytes in cursor:
try:
parts = value_bytes.decode('utf-8').split('|')
if len(parts) >= 2:
exceptions.add(frozenset((parts[0], parts[1])))
except Exception:
continue
return exceptions
def get_all_pending_duplicates(self): def get_all_pending_duplicates(self):
"""Retrieves all pending duplicate pairs from the database.""" """Retrieves all pending duplicate pairs from the database."""
results = [] results = []
@@ -646,13 +685,19 @@ class DuplicateDetector(QThread):
dirty_hashes_objs = set() dirty_hashes_objs = set()
dirty_paths = set() dirty_paths = set()
paths_to_hash_parallel = [] paths_to_hash_parallel = []
processed_initial = 0
for i, path in enumerate(self.paths_to_scan):
if not self._is_running:
break
for path in self.paths_to_scan:
try: try:
stat_info = os.stat(path) stat_info = os.stat(path)
mtime = stat_info.st_mtime mtime = stat_info.st_mtime
dev, inode = stat_info.st_dev, struct.pack('Q', stat_info.st_ino) dev, inode = stat_info.st_dev, struct.pack('Q', stat_info.st_ino)
# Update UI during initial cache check (Phase 1 part A)
processed_initial += 1
cached_h = \ cached_h = \
self.duplicate_cache.get_hash_for_path(path, mtime, dev, inode) self.duplicate_cache.get_hash_for_path(path, mtime, dev, inode)
@@ -661,18 +706,27 @@ class DuplicateDetector(QThread):
else: else:
dirty_paths.add(path) dirty_paths.add(path)
paths_to_hash_parallel.append((path, mtime, dev, inode)) paths_to_hash_parallel.append((path, mtime, dev, inode))
if time.perf_counter() - last_update_time > 0.05:
# Scale this part to 0-50% of the total bar
progress = int((processed_initial / total_files) * total_files)
self.progress_update.emit(
progress, total_files * 2,
UITexts.DUPLICATE_MSG_HASHING.format(filename=os.path.basename(path)))
last_update_time = time.perf_counter()
except OSError: except OSError:
continue continue
# Phase 1 starts with files already found in cache or skipped
processed_hashing = total_files - len(paths_to_hash_parallel)
if paths_to_hash_parallel and self._is_running: if paths_to_hash_parallel and self._is_running:
batch_size = pool.maxThreadCount() * 2 batch_size = pool.maxThreadCount() * 2
results_mutex = QMutex() results_mutex = QMutex()
new_hashes = {} new_hashes = {}
sem = QSemaphore(0) sem = QSemaphore(0)
# Phase 1 part B: Parallel hashing for new/changed files
processed_hashing = total_files - len(paths_to_hash_parallel)
for i in range(0, len(paths_to_hash_parallel), batch_size): for i in range(0, len(paths_to_hash_parallel), batch_size):
if not self._is_running: if not self._is_running:
break break
@@ -681,14 +735,14 @@ class DuplicateDetector(QThread):
pool.start(HashWorker( pool.start(HashWorker(
p_data[0], self, new_hashes, results_mutex, sem)) p_data[0], self, new_hashes, results_mutex, sem))
for _ in range(len(current_batch)): for j in range(len(current_batch)):
while not sem.tryAcquire(1, 100): while not sem.tryAcquire(1, 100):
if not self._is_running: if not self._is_running:
break break
if not self._is_running: if not self._is_running:
break break
processed_hashing += 1 processed_hashing += 1
if time.perf_counter() - last_update_time > 0.05: if time.perf_counter() - last_update_time > 0.03:
self.progress_update.emit( self.progress_update.emit(
processed_hashing, total_files * 2, processed_hashing, total_files * 2,
UITexts.DUPLICATE_MSG_HASHING.format(filename="...")) UITexts.DUPLICATE_MSG_HASHING.format(filename="..."))
@@ -705,16 +759,26 @@ class DuplicateDetector(QThread):
self.detection_finished.emit() self.detection_finished.emit()
return return
# Signal phase transition to exactly 50%
self.progress_update.emit(
total_files, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
# 3. Phase 2: Comparison (Optimized with BK-Tree) # 3. Phase 2: Comparison (Optimized with BK-Tree)
hash_map = collections.defaultdict(list) hash_map = collections.defaultdict(list)
bk_tree = BKTree(lambda a, b: a - b) bk_tree = BKTree(lambda a, b: a - b)
for p, (h_str, dev, inode) in path_to_hash.items(): path_items = list(path_to_hash.items())
total_items = len(path_items)
for i, (p, (h_str, dev, inode)) in enumerate(path_items):
if not self._is_running:
break
# Sub-phase: Indexing hashes into the BK-Tree for comparison
if time.perf_counter() - last_update_time > 0.05 or i == 0 or i == total_items - 1:
# Scale Indexing to 50% - 75% range of the total bar
indexing_progress = int((i / total_items) * (total_files / 2)) if total_items > 0 else 0
self.progress_update.emit(
total_files + indexing_progress, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
last_update_time = time.perf_counter()
h_obj = imagehash.hex_to_hash(h_str) h_obj = imagehash.hex_to_hash(h_str)
if h_obj not in hash_map: if h_obj not in hash_map:
bk_tree.add(h_obj) bk_tree.add(h_obj)
@@ -729,6 +793,19 @@ class DuplicateDetector(QThread):
hashes_to_query = list(dirty_hashes_objs) \ hashes_to_query = list(dirty_hashes_objs) \
if not self.force_full else list(hash_map.keys()) if not self.force_full else list(hash_map.keys())
total_queries = len(hashes_to_query) total_queries = len(hashes_to_query)
pending_db_updates = []
# Pre-load exceptions into memory to avoid thousands of DB lookups
self.progress_update.emit(
total_files, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
exceptions_set = self.duplicate_cache.get_all_exceptions_set()
if total_queries == 0:
# Nothing new to analyze, jump to end of detection phase
self.progress_update.emit(
total_files * 2, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="... (OK)"))
for i, h1 in enumerate(hashes_to_query): for i, h1 in enumerate(hashes_to_query):
if not self._is_running: if not self._is_running:
@@ -736,12 +813,13 @@ class DuplicateDetector(QThread):
items1 = hash_map[h1] items1 = hash_map[h1]
if time.perf_counter() - last_update_time > 0.1: # Update progress more frequently during analysis phase
# Scale Phase 2 progress to the 50%-100% range if time.perf_counter() - last_update_time > 0.05 or i == 0 or i == total_queries - 1:
phase2_progress = int(((i + 1) / total_queries) * total_files) \ # Scale Comparison to 75% - 100% range
if total_queries > 0 else total_files comparison_progress = int(((i + 1) / total_queries) * (total_files / 2)) \
if total_queries > 0 else (total_files / 2)
self.progress_update.emit( self.progress_update.emit(
total_files + phase2_progress, total_files * 2, int(total_files * 1.5 + comparison_progress), total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename="...")) UITexts.DUPLICATE_MSG_ANALYZING.format(filename="..."))
last_update_time = time.perf_counter() last_update_time = time.perf_counter()
@@ -764,15 +842,34 @@ class DuplicateDetector(QThread):
canonical = frozenset((p1, p2)) canonical = frozenset((p1, p2))
if not self._is_running: if not self._is_running:
break break
if canonical not in unique_duplicate_pairs: if canonical not in unique_duplicate_pairs:
if not self.duplicate_cache.is_exception(p1, p2): if canonical not in exceptions_set:
sim = int((1.0 - (distance / MAX_DHASH_DISTANCE)) * 100) sim = int((1.0 - (distance / MAX_DHASH_DISTANCE)) * 100)
ts = int(time.time()) ts = int(time.time())
res = DuplicateResult(p1, p2, str(h1), False, sim, ts) res = DuplicateResult(p1, p2, str(h1), False, sim, ts)
found_duplicates.append(res) found_duplicates.append(res)
unique_duplicate_pairs.add(canonical) unique_duplicate_pairs.add(canonical)
self.duplicate_cache.mark_as_pending(
p1, p2, True, similarity=sim, timestamp=ts) # Frequent UI heartbeat for large duplicate groups
if time.perf_counter() - last_update_time > 0.05:
phase2_progress = int(((i + 1) / total_queries) * total_files)
self.progress_update.emit(
total_files + phase2_progress, total_files * 2,
UITexts.DUPLICATE_MSG_ANALYZING.format(filename=os.path.basename(p1)))
last_update_time = time.perf_counter()
# Collect for batch update to improve performance
pending_db_updates.append((p1, p2, sim, ts))
# Periodically flush pending updates to DB
if len(pending_db_updates) >= 50:
self.duplicate_cache.mark_as_pending_batch(pending_db_updates)
pending_db_updates = []
# Final flush of remaining updates
if pending_db_updates:
self.duplicate_cache.mark_as_pending_batch(pending_db_updates)
self.duplicates_found.emit(found_duplicates) self.duplicates_found.emit(found_duplicates)
self.detection_finished.emit() self.detection_finished.emit()

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "bagheeraview" name = "bagheeraview"
version = "0.9.17" version = "0.9.18"
authors = [ authors = [
{ name = "Ignacio Serantes" } { name = "Ignacio Serantes" }
] ]

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="bagheeraview", name="bagheeraview",
version="0.9.17", version="0.9.18",
author="Ignacio Serantes", author="Ignacio Serantes",
description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind", description="Bagheera Image Viewer - An image viewer for KDE with Baloo in mind",
long_description="A fast image viewer built with PySide6, featuring search and " long_description="A fast image viewer built with PySide6, featuring search and "