# splash_utils.py - PySide6 Version import sys import time import atexit import threading from PySide6.QtWidgets import QApplication, QWidget, QLabel, QProgressBar, QVBoxLayout from PySide6.QtCore import Qt, QTimer, QEventLoop, QObject, Signal, QMetaObject, Q_ARG from PySide6.QtGui import QFont, QPalette, QColor, QPixmap, QFontMetrics class SplashManager(QObject): """PySide6 splash screen manager - thread-safe with signals""" # Qt signals for thread-safe UI updates _status_update_signal = Signal(str) _progress_update_signal = Signal(int) def __init__(self): super().__init__() self.splash_window = None self.app = None self._status_text = "Initializing..." self.progress_value = 0 # Track actual progress 0-100 self.timer = None self.status_label = None self.progress_bar = None self.progress_label = None # Scale factor for low-res/HiDPI displays (computed in start_splash) self._ui_scale = 1.0 self._icon_logical_px = 110 self._main_thread_id = threading.current_thread().ident self._closed = False # Connect signals to slots self._status_update_signal.connect(self._do_update_status) self._progress_update_signal.connect(self._do_set_progress) def _set_windows_taskbar_icon(self): """Set Windows taskbar icon early, before any windows are created""" try: import os import ctypes import platform from PySide6.QtGui import QIcon if platform.system() != 'Windows': return # Get icon path if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS else: base_dir = os.path.dirname(os.path.abspath(__file__)) ico_path = os.path.join(base_dir, 'Halgakos.ico') if os.path.isfile(ico_path): # Set app user model ID immediately ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('Glossarion.Translator.7.8.1') # Set app-level icon if self.app: icon = QIcon(ico_path) self.app.setWindowIcon(icon) print("✅ Windows taskbar icon set") except Exception as e: print(f"⚠️ Could not set early taskbar icon: {e}") def _set_win32_icon(self, ico_path): """Set window icon using Win32 API directly - called after window is shown""" try: import ctypes import platform if platform.system() != 'Windows' or not self.splash_window: return # Get window handle hwnd = int(self.splash_window.winId()) # Constants for WM_SETICON ICON_SMALL = 0 ICON_BIG = 1 WM_SETICON = 0x0080 # Load icon using Win32 API hicon = ctypes.windll.shell32.ExtractIconW(ctypes.windll.kernel32.GetModuleHandleW(None), ico_path, 0) if hicon and hicon != -1: ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon) ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon) print("✅ Win32 taskbar icon set on splash window") else: print("⚠️ Failed to extract icon from file") except Exception as e: print(f"⚠️ Could not set Win32 icon: {e}") def start_splash(self): """Create splash window with PySide6""" try: print("🎨 Starting PySide6 splash screen...") # Create QApplication if it doesn't exist if not QApplication.instance(): self.app = QApplication(sys.argv) else: self.app = QApplication.instance() # Set Windows taskbar icon IMMEDIATELY (before creating any windows) self._set_windows_taskbar_icon() # Create main splash widget self.splash_window = QWidget() self.splash_window.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) self.splash_window.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, False) # Screen geometry and responsive UI scale screen = self.app.primaryScreen().availableGeometry() try: # Scale down on low-res displays so text + icon don't collide. # Clamp so normal screens keep current sizes. self._ui_scale = max(0.72, min(1.0, screen.height() / 900.0, screen.width() / 900.0)) except Exception: self._ui_scale = 1.0 self._icon_logical_px = int(max(72, min(110, 110 * self._ui_scale))) # Initial target size (we'll finalize after building widgets using sizeHint) width = int(screen.width() * 0.24) height = int(screen.height() * 0.27) self.splash_window.resize(max(320, width), max(240, height)) # Set dark background with border palette = self.splash_window.palette() palette.setColor(QPalette.ColorRole.Window, QColor('#2b2b2b')) self.splash_window.setPalette(palette) self.splash_window.setAutoFillBackground(True) # Add border using stylesheet (only to main window, not children) self.splash_window.setStyleSheet(""" QWidget#splash_main { background-color: #2b2b2b; border: 1px solid #3d4450; border-radius: 8px; } """) self.splash_window.setObjectName("splash_main") # Main layout with tighter spacing layout = QVBoxLayout() m = int(max(10, 15 * self._ui_scale)) layout.setContentsMargins(m, m, m, m) layout.setSpacing(int(max(4, 8 * self._ui_scale))) # Get icon path early import os if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS else: base_dir = os.path.dirname(os.path.abspath(__file__)) self.ico_path = os.path.join(base_dir, 'Halgakos.ico') # Load and add icon self._load_icon(layout) # Title title_label = QLabel("Glossarion v7.8.1") title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) title_font = QFont("Arial", int(max(14, 20 * self._ui_scale)), QFont.Weight.Bold) title_label.setFont(title_font) title_label.setStyleSheet("color: #4a9eff; background: transparent;") layout.addWidget(title_label) # Subtitle subtitle_label = QLabel("Advanced AI Translation Suite") subtitle_label.setAlignment(Qt.AlignmentFlag.AlignCenter) subtitle_font = QFont("Arial", int(max(9, 12 * self._ui_scale))) subtitle_label.setFont(subtitle_font) subtitle_label.setStyleSheet("color: #cccccc; background: transparent;") layout.addWidget(subtitle_label) layout.addSpacing(int(max(2, 6 * self._ui_scale))) # Status label: keep single line and elide to fit width (prevents overlap on small screens) self.status_label = QLabel(self._status_text) self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) status_font = QFont("Arial", int(max(9, 11 * self._ui_scale))) self.status_label.setFont(status_font) self.status_label.setWordWrap(False) self.status_label.setFixedHeight(int(max(22, 30 * self._ui_scale))) self.status_label.setStyleSheet("color: #ffffff; background: transparent;") layout.addWidget(self.status_label) # Progress bar self.progress_bar = QProgressBar() self.progress_bar.setMinimum(0) self.progress_bar.setMaximum(100) self.progress_bar.setValue(0) self.progress_bar.setTextVisible(False) self.progress_bar.setFixedHeight(int(max(26, 36 * self._ui_scale))) self.progress_bar.setStyleSheet(""" QProgressBar { border: 2px solid #666666; border-radius: 5px; background-color: #1a1a1a; text-align: center; } QProgressBar::chunk { background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #6bb6ff, stop:1 #4a9eff); border-radius: 3px; } """) layout.addWidget(self.progress_bar) # Progress percentage label (overlaid on progress bar) self.progress_label = QLabel("0%", self.progress_bar) self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) progress_font = QFont("Montserrat", int(max(10, 12 * self._ui_scale)), QFont.Weight.Bold) self.progress_label.setFont(progress_font) self.progress_label.setStyleSheet(""" color: #ffffff; background: transparent; border: none; """) # Position label to match progress bar size dynamically # Use a timer to ensure the progress bar has been laid out first def position_progress_label(): if self.progress_bar and self.progress_label: bar_width = self.progress_bar.width() bar_height = self.progress_bar.height() self.progress_label.setGeometry(0, 0, bar_width, bar_height) QTimer.singleShot(0, position_progress_label) self.progress_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) layout.addSpacing(3) # Reduced from 5 to 3 # Version info version_label = QLabel("Starting up...") version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) version_font = QFont("Arial", int(max(8, 9 * self._ui_scale))) version_label.setFont(version_font) version_label.setStyleSheet("color: #888888; background: transparent;") layout.addWidget(version_label) layout.addStretch() self.splash_window.setLayout(layout) # Finalize splash size: never smaller than its content's sizeHint. try: layout.activate() hint = self.splash_window.sizeHint() target_w = max(self.splash_window.width(), hint.width()) target_h = max(self.splash_window.height(), hint.height()) # Clamp to screen to avoid going off-screen target_w = min(target_w, int(screen.width() * 0.92)) target_h = min(target_h, int(screen.height() * 0.92)) self.splash_window.setFixedSize(int(target_w), int(target_h)) except Exception: pass # Center the window x = (screen.width() - self.splash_window.width()) // 2 y = (screen.height() - self.splash_window.height()) // 2 self.splash_window.move(x, y) # Show splash self.splash_window.show() # Process events to ensure window is fully created self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # Set Win32 taskbar icon IMMEDIATELY after window is shown self._set_win32_icon(self.ico_path) # Start progress animation self._animate_progress() # Process events again self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # Register cleanup atexit.register(self.close_splash) print("✅ PySide6 splash screen displayed") return True except Exception as e: print(f"⚠️ Could not start splash: {e}") import traceback traceback.print_exc() return False def _load_icon(self, layout): """Load the Halgakos.ico icon""" try: import os from PySide6.QtGui import QIcon, QPixmap from PySide6.QtCore import QSize if getattr(sys, 'frozen', False): # Running as .exe base_dir = sys._MEIPASS else: # Running as .py files base_dir = os.path.dirname(os.path.abspath(__file__)) ico_path = os.path.join(base_dir, 'Halgakos.ico') if os.path.isfile(ico_path): # Set window icon for taskbar icon = QIcon(ico_path) self.splash_window.setWindowIcon(icon) # Also set as application icon for taskbar on Windows if self.app: self.app.setWindowIcon(icon) # For Windows: Set taskbar icon via Win32 API (works when running as .py script) try: import ctypes import platform if platform.system() == 'Windows': # Set app user model ID ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('Glossarion.Translator.7.8.1') # Load icon from file and set it on the window hwnd = int(self.splash_window.winId()) # Constants for WM_SETICON ICON_SMALL = 0 ICON_BIG = 1 WM_SETICON = 0x0080 # Load icon using Win32 API hicon = ctypes.windll.shell32.ExtractIconW(ctypes.windll.kernel32.GetModuleHandleW(None), ico_path, 0) if hicon: ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon) ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, hicon) except Exception as e: print(f"⚠️ Could not set Windows taskbar icon: {e}") # HiDPI-aware, high-quality scaling try: dpr = self.splash_window.devicePixelRatioF() except Exception: dpr = 1.0 # Logical size (how big it appears on screen) target_logical = int(getattr(self, '_icon_logical_px', 110) or 110) # Physical pixels (actual pixels in the image for HiDPI) target_dev_px = int(target_logical * dpr) # Load and scale the icon at device pixel resolution raw = QPixmap(ico_path) if not raw.isNull(): # Scale to device pixels with smooth transformation, keeping aspect ratio scaled = raw.scaled( target_dev_px, target_dev_px, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) # Set device pixel ratio so Qt knows this is a HiDPI image scaled.setDevicePixelRatio(dpr) # Create label with logical size icon_label = QLabel() icon_label.setPixmap(scaled) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) icon_label.setStyleSheet("background: transparent;") # Fixed size in logical pixels (Qt will automatically use HiDPI pixmap) icon_label.setFixedSize(target_logical, target_logical) icon_label.setScaledContents(False) layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) return except Exception as e: print(f"⚠️ Could not load icon: {e}") import traceback traceback.print_exc() # Fallback emoji if icon loading fails icon_label = QLabel("📚") icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) icon_font = QFont("Arial", int(max(36, 56 * getattr(self, '_ui_scale', 1.0)))) icon_label.setFont(icon_font) icon_label.setStyleSheet("background: #4a9eff; color: white; border-radius: 10px;") sz = int(getattr(self, '_icon_logical_px', 110) or 110) icon_label.setFixedSize(sz, sz) layout.addWidget(icon_label, alignment=Qt.AlignmentFlag.AlignCenter) def _animate_progress(self): """Animate progress bar filling up""" if not self.timer: self.timer = QTimer() self.timer.timeout.connect(self._update_progress) self.timer.start(100) # Update every 100ms def _update_progress(self): """Update progress animation""" try: # Skip auto-animation if manual control is active if getattr(self, '_manual_progress', False): return if self.splash_window and self.progress_value < 100: # Auto-increment progress for visual effect during startup if self.progress_value < 30: self.progress_value += 8 # Fast initial progress elif self.progress_value < 70: self.progress_value += 4 # Medium progress elif self.progress_value < 90: self.progress_value += 2 # Slow progress else: self.progress_value += 1 # Very slow final progress # Cap at 99% until explicitly set to 100% if self.progress_value >= 99: self.progress_value = 99 # Update progress bar if self.progress_bar: self.progress_bar.setValue(self.progress_value) # Update percentage text if self.progress_label: self.progress_label.setText(f"{self.progress_value}%") # Process events if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) except Exception: pass def _elide_status(self, message: str) -> str: try: if not self.status_label: return message w = int(self.status_label.width() or 0) if w <= 20: return message fm = QFontMetrics(self.status_label.font()) # Keep a little padding return fm.elidedText(str(message), Qt.TextElideMode.ElideRight, max(20, w - 10)) except Exception: return message def update_status(self, message): """Update splash status and progress - thread-safe""" if self._closed: return self._status_text = message # Enhanced progress mapping progress_map = { "Loading theme framework...": 5, "Loading UI framework...": 8, # Script validation phase - 10-25% "Scanning Python modules...": 10, "Validating": 12, # Partial match for "Validating X Python scripts..." "✅ All scripts validated": 25, # Module loading phase - 30-85% "Loading translation modules...": 30, "Initializing module system...": 35, "Loading translation engine...": 40, "Validating translation engine...": 45, "✅ translation engine loaded": 50, "Loading glossary extractor...": 55, "Validating glossary extractor...": 60, "✅ glossary extractor loaded": 65, "Loading EPUB converter...": 70, "✅ EPUB converter loaded": 75, "Loading QA scanner...": 78, "✅ QA scanner loaded": 82, "Finalizing module initialization...": 85, "✅ All modules loaded successfully": 88, "Creating main window...": 92, "Ready!": 100 } # Use thread-safe signal to update UI try: self._status_update_signal.emit(message) except Exception: pass # Check for progress updates try: # Check for exact matches first if message in progress_map: self.set_progress(progress_map[message]) else: # Check for partial matches for key, value in progress_map.items(): if key in message: self.set_progress(value) break except Exception: pass def _do_update_status(self, message): """Internal method to update status - called on main thread via signal""" try: if self.splash_window and self.status_label and not self._closed: self.status_label.setText(self._elide_status(message)) # Process events if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) except (RuntimeError, AttributeError): # Widget deleted or not available pass def set_progress(self, value): """Manually set progress value (0-100) - thread-safe""" if self._closed: return self.progress_value = max(0, min(100, value)) # Use thread-safe signal to update UI try: self._progress_update_signal.emit(self.progress_value) except Exception: pass def _do_set_progress(self, value): """Internal method to set progress - called on main thread via signal""" try: if not self._closed: if self.progress_bar: self.progress_bar.setValue(value) if self.progress_label: self.progress_label.setText(f"{value}%") # Process events to ensure smooth UI updates if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) except (RuntimeError, AttributeError): # Widget deleted or not available pass def validate_all_scripts(self, base_dir=None): """Validate that all Python scripts in the project compile without syntax errors Args: base_dir: Directory to scan for Python files. Defaults to script directory. Returns: tuple: (success_count, total_count, failed_scripts) """ import os import py_compile # Enable manual progress control and stop auto-animation timer self._manual_progress = True if self.timer: self.timer.stop() if base_dir is None: if getattr(sys, 'frozen', False): base_dir = sys._MEIPASS else: base_dir = os.path.dirname(os.path.abspath(__file__)) self.update_status("Scanning Python modules...") self.set_progress(10) # Explicitly set starting progress # Find all Python files python_files = [] try: for file in os.listdir(base_dir): if file.endswith('.py') and not file.startswith('.'): python_files.append(os.path.join(base_dir, file)) except Exception as e: print(f"⚠️ Could not scan directory: {e}") return (0, 0, []) # Define optional scripts that might be missing in Lite version optional_scripts = [ 'manga_translator.py', 'manga_integration.py', 'manga_settings_dialog.py', 'manga_image_preview.py', 'bubble_detector.py', 'local_inpainter.py', 'ocr_manager.py', 'ImageRenderer.py' ] # Identify which optional scripts are actually missing missing_optional = [] existing_basenames = {os.path.basename(p) for p in python_files} for script in optional_scripts: if script not in existing_basenames: missing_optional.append(script) total_count = len(python_files) + len(missing_optional) success_count = 0 failed_scripts = [] if total_count == 0: return (0, 0, []) self.update_status(f"Validating {total_count} Python scripts...") print(f"🔍 Validating {total_count} Python scripts for compilation errors...") current_idx = 0 # Check each existing file for filepath in python_files: current_idx += 1 filename = os.path.basename(filepath) # Log progress for debugging hangs print(f"📂 Scanning files: [{current_idx}/{total_count}] {filename}") try: # Try to compile the file py_compile.compile(filepath, doraise=True) success_count += 1 print(f"✅ Validated {current_idx}/{total_count}: {filename}") # Update progress based on validation progress # Map 0-100% of files to 15-25% of total progress progress_pct = 15 + int((current_idx / total_count) * 10) # Only update if progress actually changed (avoid animation churn) if progress_pct != self.progress_value: self.set_progress(progress_pct) except SyntaxError as e: failed_scripts.append((filename, str(e))) print(f"❌ Syntax error in {filename}: {e}") import traceback print(f"Full traceback:\n{traceback.format_exc()}") except Exception as e: failed_scripts.append((filename, str(e))) print(f"⚠️ Could not validate {filename}: {e}") import traceback print(f"Full traceback:\n{traceback.format_exc()}") # Process events to keep UI responsive if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # Simulate missing optional scripts for script in missing_optional: current_idx += 1 print(f"📂 Scanning files: [{current_idx}/{total_count}] {script} (Simulated)") # Simulate validation delay time.sleep(0.05) # 50ms delay success_count += 1 # Count as success print(f"✅ Validated {current_idx}/{total_count}: {script} (Simulated)") progress_pct = 15 + int((current_idx / total_count) * 10) if progress_pct != self.progress_value: self.set_progress(progress_pct) if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) # Report results if failed_scripts: self.update_status(f"⚠️ {success_count}/{total_count} scripts valid") print(f"\n⚠️ Validation complete: {success_count}/{total_count} scripts compiled successfully") print(f"Failed scripts:") for script, error in failed_scripts: print(f" • {script}: {error}") else: self.update_status("✅ All scripts validated") print(f"✅ All {total_count} Python scripts validated successfully") # Re-enable auto-animation after validation completes self._manual_progress = False return (success_count, total_count, failed_scripts) def simulate_validation(self): """Simulate validation progress smoothly when skipping validation""" # Stop auto-animation to prevent fighting if self.timer: self.timer.stop() self._manual_progress = True self.update_status("Scanning Python modules...") # Ensure we don't go backwards start = self.progress_value target = 25 if start < target: # Animate quickly to target over ~0.5 seconds steps = target - start delay = 0.5 / steps if steps > 0 else 0.05 self.update_status("Validating scripts (Skipped)...") for i in range(1, steps + 1): self.set_progress(start + i) time.sleep(delay) if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) self.update_status("✅ All scripts validated") # Re-enable auto-animation flag (though timer needs restart if desired) self._manual_progress = False def close_splash(self): """Close the splash screen""" try: # Mark as closed to prevent any further updates from worker threads self._closed = True # Stop timer first if self.timer: self.timer.stop() self.timer = None # Update progress to 100% before closing (only if window still exists) if self.splash_window: try: self.progress_value = 100 if self.progress_bar: self.progress_bar.setValue(100) if self.progress_label: self.progress_label.setText("100%") # Process events one last time if self.app: self.app.processEvents(QEventLoop.ExcludeUserInputEvents) time.sleep(0.1) except RuntimeError: # Widget already deleted by Qt, that's okay pass # Close splash window try: self.splash_window.close() except RuntimeError: # Already deleted pass self.splash_window = None print("✅ Splash screen closed") except Exception as e: print(f"⚠️ Error closing splash: {e}") finally: # Ensure cleanup self.timer = None self.splash_window = None self.progress_bar = None self.progress_label = None self.status_label = None