Glossarion / splash_utils.py
Shirochi's picture
Upload 11 files
4b70c44 verified
# 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