From 1e89b566d1edac759ff7e4b713fa6d33a0a263d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Mon, 20 Oct 2025 01:17:14 +0300 Subject: [PATCH] Added a loading indicator to the book handler window --- CHANGELOG.md | 1 + abogen/book_handler.py | 155 ++++++++++++++++++++++++++++++++++------- abogen/gui.py | 1 - 3 files changed, 130 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7390a1d..d4c2c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # 1.2.0 (pre-release) - Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94. +- Added a loading indicator to the book handler window for better user experience during book preprocessing. - Fixed `cannot access local variable 'is_narrow'` error when subtitle format `SRT` was selected, mentioned by @Kinasa0096 in #88. - Fixed folder and filename sanitization to properly handle OS-specific illegal characters (Windows, Linux, macOS), ensuring compatibility across all platforms when creating chapter folders and files. - Fixed `/` and `\` path display by normalizing paths. diff --git a/abogen/book_handler.py b/abogen/book_handler.py index fc0f65e..0843847 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -1,5 +1,4 @@ import re -import html import ebooklib import base64 import fitz # PyMuPDF for PDF support @@ -21,7 +20,7 @@ from PyQt5.QtWidgets import ( QMenu, QLabel, ) -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QThread, pyqtSignal from abogen.utils import clean_text, calculate_text_length, detect_encoding import os import logging # Add logging @@ -46,6 +45,20 @@ class HandlerDialog(QDialog): # Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown) _content_cache = {} + class _LoaderThread(QThread): + """Minimal QThread that runs a callable and emits an error string on exception.""" + error = pyqtSignal(str) + + def __init__(self, target_callable): + super().__init__() + self._target = target_callable + + def run(self): + try: + self._target() + except Exception as e: + self.error.emit(str(e)) + @classmethod def clear_content_cache(cls, book_path=None): """Clear the content cache. If book_path is provided, only clear that book's cache.""" @@ -157,14 +170,11 @@ class HandlerDialog(QDialog): # Initialize checked_chapters set self.checked_chapters = set(checked_chapters) if checked_chapters else set() - # For storing content and lengths + # For storing content and lengths (will be filled by background loader) self.content_texts = {} self.content_lengths = {} - # Pre-process content based on file type - self._preprocess_content() - - # Add "Information" item at the beginning of the tree + # Add a placeholder "Information" item so the tree isn't empty immediately info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) info_item.setData(0, Qt.UserRole, "info:bookinfo") info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable) @@ -172,8 +182,18 @@ class HandlerDialog(QDialog): font.setBold(True) info_item.setFont(0, font) - # Build tree based on file type - self._build_tree() + # Setup UI now so dialog appears immediately + self._setup_ui() + + # Create a centered loading overlay and show it while background load runs + self._create_loading_overlay() + # Hide the main UI so only the overlay is visible initially + if getattr(self, 'splitter', None) is not None: + self.splitter.setVisible(False) + self._show_loading_overlay("Loading...") + + # Start background loading of book content so the dialog opens immediately + self._start_background_load() # Hide expand/collapse decoration if there are no parent items has_parents = False @@ -183,27 +203,99 @@ class HandlerDialog(QDialog): break self.treeWidget.setRootIsDecorated(has_parents) - # Setup UI (creates save_chapters_checkbox and other UI elements) - self._setup_ui() - # Run auto-check after UI is setup - if not self._are_provided_checks_relevant(): - self._run_auto_check() - # Connect signals - self.treeWidget.currentItemChanged.connect(self.update_preview) - self.treeWidget.itemChanged.connect(self.handle_item_check) - self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states()) - self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) + def _create_loading_overlay(self): + """Create a simple centered QLabel used as loading indicator. - # Select first item and expand all - self.treeWidget.expandAll() - if self.treeWidget.topLevelItemCount() > 0: - self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) - self.treeWidget.setFocus() + This label is added to the dialog's main layout above the splitter so + when the splitter is hidden only the text is visible. + """ + try: + label = QLabel(self) + label.setAlignment(Qt.AlignCenter) + # larger plain text per request + label.setStyleSheet("font-size: 14pt;") + label.setVisible(False) + # insert at top of main layout if present, otherwise keep as child + try: + layout = self.layout() + if layout is not None: + layout.insertWidget(0, label) + except Exception: + pass + self._loading_label = label + except Exception: + self._loading_label = None - # Update checkbox states - self._update_checkbox_states() + def _show_loading_overlay(self, text: str): + lbl = getattr(self, '_loading_label', None) + if lbl is None: + return + lbl.setText(text) + lbl.setVisible(True) + + def _hide_loading_overlay(self): + lbl = getattr(self, '_loading_label', None) + if lbl is None: + return + lbl.setVisible(False) + + + def _start_background_load(self): + """Start a QThread that runs the preprocessing in background.""" + # Start a minimal QThread which executes _preprocess_content + self._loader_thread = HandlerDialog._LoaderThread(self._preprocess_content) + self._loader_thread.finished.connect(self._on_load_finished) + self._loader_thread.error.connect(self._on_load_error) + # ensure thread instance is deleted when done + self._loader_thread.finished.connect(self._loader_thread.deleteLater) + self._loader_thread.start() + + def _on_load_error(self, err_msg): + logging.error(f"Error loading book in background: {err_msg}") + if getattr(self, 'previewEdit', None) is not None: + self.previewEdit.setPlainText(f"Error loading book: {err_msg}") + if getattr(self, 'splitter', None) is not None: + self.splitter.setVisible(True) + self._hide_loading_overlay() + + def _on_load_finished(self): + """Called in the main thread when background loading finished.""" + # Build the tree now that content_texts/content_lengths/etc. are ready + try: + # Rebuild tree based on file type + self._build_tree() + + # Run auto-check if no provided checks are relevant + if not self._are_provided_checks_relevant(): + self._run_auto_check() + + # Connect signals (after tree exists) + self.treeWidget.currentItemChanged.connect(self.update_preview) + self.treeWidget.itemChanged.connect(self.handle_item_check) + self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states()) + self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) + + # Expand and select first item + self.treeWidget.expandAll() + if self.treeWidget.topLevelItemCount() > 0: + self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) + self.treeWidget.setFocus() + + # Update checkbox states + self._update_checkbox_states() + + # Update preview for the current selection + current = self.treeWidget.currentItem() + self.update_preview(current) + + except Exception as e: + logging.error(f"Error finalizing book load: {e}") + # Show the main UI and hide loading text + if getattr(self, 'splitter', None) is not None: + self.splitter.setVisible(True) + self._hide_loading_overlay() def _preprocess_content(self): """Pre-process content from the document""" @@ -1958,6 +2050,17 @@ class HandlerDialog(QDialog): return metadata def get_selected_text(self): + # If a background loader thread is running, wait for it to finish to + # preserve compatibility with callers that expect content to be ready + # when they create a HandlerDialog and immediately request selected text. + try: + if hasattr(self, '_loader_thread') and getattr(self, '_loader_thread') is not None: + # Wait for thread to finish (blocks until done) + if self._loader_thread.isRunning(): + self._loader_thread.wait() + except Exception: + pass + if self.file_type == "epub": return self._get_epub_selected_text() elif self.file_type == "markdown": diff --git a/abogen/gui.py b/abogen/gui.py index 4707afa..08f03dc 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -85,7 +85,6 @@ from abogen.voice_profiles import load_profiles # Import ctypes for Windows-specific taskbar icon if platform.system() == "Windows": import ctypes - import winreg class DarkTitleBarEventFilter(QObject):