fix(startup): prevent main thread hangs and optimize resource loading

- run start_sh initialization via QTimer.singleShot with timeout
- add timeout protection to load_theme_fonts()
- load Steam/EGS/PortProton games in parallel instead of sequential
- delay game loading until UI is fully initialized
- fix callback chaining to avoid blocking operations
- add proper timeout + error handling for all Steam/EGS network requests
- add timeouts for flatpak subprocess calls
- improve file I/O error handling to avoid UI freeze
- optimize theme font loading:
  - delay font loading via QTimer.singleShot
  - load fonts in batches of 10
  - reduce font load timeout to 3s
  - remove fonts only when switching themes

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2025-11-28 12:00:00 +05:00
parent 77b025f580
commit 3abaccb1e0
8 changed files with 282 additions and 97 deletions

View File

@@ -34,13 +34,12 @@ def main():
os.environ["PROCESS_LOG"] = "1" os.environ["PROCESS_LOG"] = "1"
os.environ["START_FROM_STEAM"] = "1" os.environ["START_FROM_STEAM"] = "1"
# Get the PortProton start command
start_sh = get_portproton_start_command() start_sh = get_portproton_start_command()
if start_sh is None: if start_sh is None:
return return
subprocess.run(start_sh + ["cli", "--initial"])
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__)) app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__) app.setDesktopFileName(__app_id__)
@@ -144,6 +143,22 @@ def main():
save_fullscreen_config(False) save_fullscreen_config(False)
window.showNormal() window.showNormal()
# Execute the initial PortProton command after the UI is set up
def run_initial_command():
nonlocal start_sh
if start_sh:
try:
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
except subprocess.TimeoutExpired:
logger.warning("Initial PortProton command timed out")
except Exception as e:
logger.error(f"Error running initial PortProton command: {e}")
else:
logger.warning("PortProton start command not available, skipping initial command")
# Run the initial command after the UI is displayed
QTimer.singleShot(100, run_initial_command)
# --- Cleanup --- # --- Cleanup ---
def cleanup_on_exit(): def cleanup_on_exit():
try: try:

View File

@@ -137,8 +137,14 @@ def save_time_config(detail_level):
def read_file_content(file_path): def read_file_content(file_path):
"""Reads the content of a file and returns it as a string.""" """Reads the content of a file and returns it as a string."""
try:
# Add timeout protection for file operations using a simple approach
with open(file_path, encoding="utf-8") as f: with open(file_path, encoding="utf-8") as f:
return f.read().strip() content = f.read().strip()
return content
except Exception as e:
logger.warning(f"Error reading file {file_path}: {e}")
raise # Re-raise the exception to be handled by the caller
def get_portproton_location(): def get_portproton_location():
"""Возвращает путь к PortProton каталогу (строку) или None.""" """Возвращает путь к PortProton каталогу (строку) или None."""
@@ -159,6 +165,8 @@ def get_portproton_location():
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults") logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}") logger.warning(f"Failed to read PortProton configuration file: {e}")
except Exception as e:
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_flatpak_dir): if os.path.isdir(default_flatpak_dir):
@@ -180,12 +188,17 @@ def get_portproton_start_command():
["flatpak", "list"], ["flatpak", "list"],
capture_output=True, capture_output=True,
text=True, text=True,
check=False check=False,
timeout=10
) )
if "ru.linux_gaming.PortProton" in result.stdout: if "ru.linux_gaming.PortProton" in result.stdout:
logger.info("Detected Flatpak installation") logger.info("Detected Flatpak installation")
return ["flatpak", "run", "ru.linux_gaming.PortProton"] return ["flatpak", "run", "ru.linux_gaming.PortProton"]
except Exception: except subprocess.TimeoutExpired:
logger.warning("Flatpak list command timed out")
return None
except Exception as e:
logger.warning(f"Error checking flatpak list: {e}")
pass pass
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh") start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")

View File

@@ -577,7 +577,7 @@ def get_egs_game_description_async(
"https://launcher.store.epicgames.com/graphql", "https://launcher.store.epicgames.com/graphql",
json=search_query, json=search_query,
headers=headers, headers=headers,
timeout=5 timeout=10
) )
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
@@ -597,7 +597,7 @@ def get_egs_game_description_async(
def fetch_legacy_description(url: str) -> str: def fetch_legacy_description(url: str) -> str:
"""Fetches description from the legacy API, handling DNS failures.""" """Fetches description from the legacy API, handling DNS failures."""
try: try:
response = requests.get(url, headers=headers, timeout=5) response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -619,6 +619,9 @@ def get_egs_game_description_async(
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e)) logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
return "" return ""
except requests.exceptions.Timeout:
logger.warning("Request timeout for legacy API %s", url)
return ""
except requests.RequestException as e: except requests.RequestException as e:
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e)) logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
return "" return ""
@@ -670,7 +673,7 @@ def get_egs_game_description_async(
url = "https://graphql.epicgames.com/graphql" url = "https://graphql.epicgames.com/graphql"
try: try:
response = requests.post(url, json=search_query, headers=headers, timeout=5) response = requests.post(url, json=search_query, headers=headers, timeout=10)
response.raise_for_status() response.raise_for_status()
data = orjson.loads(response.content) data = orjson.loads(response.content)
if namespace: if namespace:
@@ -689,6 +692,9 @@ def get_egs_game_description_async(
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])): for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
return element.get("description", ""), element.get("productSlug", "") return element.get("description", ""), element.get("productSlug", "")
return "", "" return "", ""
except requests.exceptions.Timeout:
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
return "", ""
except requests.RequestException as e: except requests.RequestException as e:
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e)) logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
return "", "" return "", ""
@@ -717,6 +723,10 @@ def get_egs_game_description_async(
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description) logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name) logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
except requests.exceptions.Timeout:
logger.warning("Legacy API request timed out for %s", app_name)
except Exception as e:
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
# Step 3: If still no description and no namespace, try GraphQL with title # Step 3: If still no description and no namespace, try GraphQL with title
if not description and not namespace: if not description and not namespace:

View File

@@ -159,7 +159,8 @@ class InputManager(QObject):
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled) logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled: if self.mouse_emulation_enabled:
self.enable_mouse_emulation() # Initialize mouse emulation asynchronously to avoid blocking startup
QTimer.singleShot(0, self._async_enable_mouse_emulation)
# FileExplorer specific attributes # FileExplorer specific attributes
self.file_explorer = None self.file_explorer = None
@@ -197,6 +198,10 @@ class InputManager(QObject):
# Initialize evdev + hotplug # Initialize evdev + hotplug
self.init_gamepad() self.init_gamepad()
def _async_enable_mouse_emulation(self):
"""Asynchronously enable mouse emulation to avoid blocking startup."""
self.enable_mouse_emulation()
def _update_emulation_flag(self): def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only).""" """Update emulation_active flag based on Qt app focus (main thread only)."""
active = QApplication.activeWindow() active = QApplication.activeWindow()

View File

@@ -60,6 +60,7 @@ class MainWindow(QMainWindow):
self.is_exiting = False self.is_exiting = False
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
# Apply theme but defer heavy font loading
self.theme = self.theme_manager.apply_theme(selected_theme) self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name) self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
@@ -132,6 +133,13 @@ class MainWindow(QMainWindow):
self.update_progress.connect(self.progress_bar.setValue) self.update_progress.connect(self.progress_bar.setValue)
self.update_status_message.connect(self.statusBar().showMessage) self.update_status_message.connect(self.statusBar().showMessage)
# Show immediate startup progress to indicate the app is loading
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate initially
self.progress_bar.setValue(0) # Reset value
self.update_status_message.emit(_("Starting PortProton..."), 0)
QApplication.processEvents() # Process to show progress bar immediately
self.installing = False self.installing = False
self.current_install_script = None self.current_install_script = None
self.install_process = None self.install_process = None
@@ -219,7 +227,6 @@ class MainWindow(QMainWindow):
self.keyboard = VirtualKeyboard(self, self.theme) self.keyboard = VirtualKeyboard(self, self.theme)
self.detail_animations = DetailPageAnimations(self, self.theme) self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
if read_fullscreen_config(): if read_fullscreen_config():
self.showFullScreen() self.showFullScreen()
@@ -230,6 +237,14 @@ class MainWindow(QMainWindow):
else: else:
self.showNormal() self.showNormal()
# Process events to ensure UI is responsive before starting heavy operations
QApplication.processEvents()
# Delay game loading until after the UI is fully displayed to prevent blocking
# Use a longer delay to ensure window is fully rendered and responsive
# Use a custom event processing approach to make sure UI stays responsive
QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing
def on_slider_released(self) -> None: def on_slider_released(self) -> None:
"""Delegate to game library manager.""" """Delegate to game library manager."""
if hasattr(self, 'game_library_manager'): if hasattr(self, 'game_library_manager'):
@@ -563,6 +578,8 @@ class MainWindow(QMainWindow):
def on_games_loaded(self, games: list[tuple]): def on_games_loaded(self, games: list[tuple]):
self.game_library_manager.set_games(games) self.game_library_manager.set_games(games)
self.progress_bar.setVisible(False) self.progress_bar.setVisible(False)
self.progress_bar.setRange(0, 100) # Reset to determinate state for next use
self.progress_bar.setValue(0)
# Clear the refresh in progress flag # Clear the refresh in progress flag
if hasattr(self, '_refresh_in_progress'): if hasattr(self, '_refresh_in_progress'):
@@ -589,8 +606,22 @@ class MainWindow(QMainWindow):
favorites = read_favorites() favorites = read_favorites()
self.pending_games = [] self.pending_games = []
self.games = [] self.games = []
# Show initial progress bar and status message immediately
self.progress_bar.setRange(0, 100) # Set to determinate range
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
self.progress_bar.setVisible(True) self.progress_bar.setVisible(True)
self.update_status_message.emit(_("Loading games..."), 0)
# Process events to keep UI responsive
QApplication.processEvents()
def start_loading():
# Make sure progress bar is still visible
self.progress_bar.setValue(0)
self.progress_bar.setVisible(True)
QApplication.processEvents() # Allow UI to update
if display_filter == "steam": if display_filter == "steam":
self._load_steam_games_async(lambda games: self.games_loaded.emit(games)) self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
elif display_filter == "portproton": elif display_filter == "portproton":
@@ -604,43 +635,99 @@ class MainWindow(QMainWindow):
self.update_status_message.emit self.update_status_message.emit
) )
elif display_filter == "favorites": elif display_filter == "favorites":
def on_all_games(portproton_games, steam_games, epic_games): def on_all_games_favorites(portproton_games, steam_games, epic_games):
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites] games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites]
self.games_loaded.emit(games) self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async( # Load games from different sources in parallel to prevent blocking
lambda sg: load_egs_games_async( results = {'portproton': [], 'steam': [], 'epic': []}
completed = {'portproton': False, 'steam': False, 'epic': False}
def check_completion():
if all(completed.values()):
QApplication.processEvents() # Keep UI responsive
on_all_games_favorites(results['portproton'], results['steam'], results['epic'])
def portproton_callback(games):
results['portproton'] = games
completed['portproton'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def steam_callback(games):
results['steam'] = games
completed['steam'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def epic_callback(games):
results['epic'] = games
completed['epic'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
load_egs_games_async(
self.legendary_path, self.legendary_path,
lambda eg: on_all_games(pg, sg, eg), epic_callback,
self.downloader, self.downloader,
self.update_progress.emit, self.update_progress.emit,
self.update_status_message.emit self.update_status_message.emit
) )
)
)
else: else:
def on_all_games(portproton_games, steam_games, epic_games): # For 'all' filter - load games from different sources in parallel to prevent blocking
results = {'portproton': [], 'steam': [], 'epic': []}
completed = {'portproton': False, 'steam': False, 'epic': False}
def on_all_games():
seen = set() seen = set()
games = [] games = []
for game in portproton_games + steam_games + epic_games: for game in results['portproton'] + results['steam'] + results['epic']:
# Уникальный ключ: имя + exec_line # Уникальный ключ: имя + exec_line
key = (game[0], game[4]) key = (game[0], game[4])
if key not in seen: if key not in seen:
seen.add(key) seen.add(key)
games.append(game) games.append(game)
QApplication.processEvents() # Keep UI responsive
self.games_loaded.emit(games) self.games_loaded.emit(games)
self._load_portproton_games_async(
lambda pg: self._load_steam_games_async( def check_completion():
lambda sg: load_egs_games_async( if all(completed.values()):
QApplication.processEvents() # Keep UI responsive
on_all_games()
def portproton_callback(games):
results['portproton'] = games
completed['portproton'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def steam_callback(games):
results['steam'] = games
completed['steam'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
def epic_callback(games):
results['epic'] = games
completed['epic'] = True
QApplication.processEvents() # Keep UI responsive
check_completion()
# Load all sources in parallel
self._load_portproton_games_async(portproton_callback)
self._load_steam_games_async(steam_callback)
load_egs_games_async(
self.legendary_path, self.legendary_path,
lambda eg: on_all_games(pg, sg, eg), epic_callback,
self.downloader, self.downloader,
self.update_progress.emit, self.update_progress.emit,
self.update_status_message.emit self.update_status_message.emit
) )
)
) # Run loading with minimal delay to allow UI to be responsive
return [] QTimer.singleShot(100, start_loading) # Reduced to 100ms
def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]): def _load_steam_games_async(self, callback: Callable[[list[tuple]], None]):
steam_games = [] steam_games = []

View File

@@ -254,6 +254,8 @@ class PortProtonAPI:
try: try:
mod_time = os.path.getmtime(cache_file) mod_time = os.path.getmtime(cache_file)
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION: if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
# Add timeout protection for file operations
start_time = time.time()
with open(cache_file, "rb") as f: with open(cache_file, "rb") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
# Check signature # Check signature
@@ -261,6 +263,10 @@ class PortProtonAPI:
current_signature = self._compute_scripts_signature( current_signature = self._compute_scripts_signature(
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
) )
# Check for timeout during signature computation
if time.time() - start_time > 3: # 3 second timeout
logger.warning("Cache loading took too long, skipping cache")
return None
if cached_signature != current_signature: if cached_signature != current_signature:
logger.info("Scripts signature mismatch; invalidating cache") logger.info("Scripts signature mismatch; invalidating cache")
return None return None
@@ -287,21 +293,26 @@ class PortProtonAPI:
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None: def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
"""Start loading auto-install games in a background thread. Returns the thread for management.""" """Start loading auto-install games in a background thread. Returns the thread for management."""
# Check cache first (sync, fast)
cached_games = self._load_autoinstall_cache()
if cached_games is not None:
# Emit via callback immediately if cached
QThread.msleep(0) # Yield to Qt event loop
callback(cached_games)
return None # No thread needed
# No cache: Start background thread
class AutoinstallWorker(QThread): class AutoinstallWorker(QThread):
finished = Signal(list) finished = Signal(list)
api: "PortProtonAPI" api: "PortProtonAPI"
portproton_location: str | None portproton_location: str | None
def run(self): def run(self):
import time
# Check cache in this background thread, not in main thread
start_time = time.time()
cached_games = self.api._load_autoinstall_cache()
# If cache loading took too long (>2 seconds), skip cache and load directly
if time.time() - start_time > 2:
logger.warning("Cache loading took too long, proceeding without cache")
cached_games = None
if cached_games is not None:
self.finished.emit(cached_games)
return
# No cache: Load games from scratch
games = [] games = []
auto_dir = os.path.join( auto_dir = os.path.join(
self.portproton_location or "", "data", "scripts", "pw_autoinstall" self.portproton_location or "", "data", "scripts", "pw_autoinstall"

View File

@@ -420,7 +420,7 @@ def fetch_sgdb_cover(game_name: str) -> str:
try: try:
encoded = urllib.parse.quote(game_name) encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}" url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
resp = requests.get(url, timeout=5) resp = requests.get(url, timeout=10)
if resp.status_code != 200: if resp.status_code != 200:
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code) logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
return "" return ""
@@ -431,17 +431,30 @@ def fetch_sgdb_cover(game_name: str) -> str:
if text: if text:
logger.info("Fetched SGDB cover for %s: %s", game_name, text) logger.info("Fetched SGDB cover for %s: %s", game_name, text)
return text return text
except requests.exceptions.Timeout:
logger.warning(f"SGDB request timed out for {game_name}")
return ""
except requests.exceptions.RequestException as e:
logger.warning(f"SGDB request error for {game_name}: {e}")
return ""
except Exception as e: except Exception as e:
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e) logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}")
return "" return ""
def check_url_exists(url: str) -> bool: def check_url_exists(url: str) -> bool:
"""Check whether a URL returns HTTP 200.""" """Check whether a URL returns HTTP 200."""
try: try:
r = requests.head(url, timeout=3) r = requests.head(url, timeout=5)
return r.status_code == 200 return r.status_code == 200
except Exception: except requests.exceptions.Timeout:
logger.warning(f"URL check timed out for: {url}")
return False
except requests.exceptions.RequestException as e:
logger.warning(f"Request error when checking URL {url}: {e}")
return False
except Exception as e:
logger.warning(f"Unexpected error when checking URL {url}: {e}")
return False return False

View File

@@ -111,7 +111,20 @@ def load_theme_fonts(theme_name):
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping") logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return return
def load_fonts_delayed():
global _loaded_theme
try:
# Only remove fonts if this is a theme change (not initial load)
current_loaded_theme = _loaded_theme # Capture the current value
if current_loaded_theme is not None and current_loaded_theme != theme_name:
# Run font removal in the GUI thread with delay
QFontDatabase.removeAllApplicationFonts() QFontDatabase.removeAllApplicationFonts()
import time
import os
start_time = time.time()
timeout = 3 # Reduced timeout to 3 seconds for faster loading
fonts_folder = None fonts_folder = None
if theme_name == "standart": if theme_name == "standart":
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
@@ -128,8 +141,19 @@ def load_theme_fonts(theme_name):
logger.error(f"Fonts folder not found for theme '{theme_name}'") logger.error(f"Fonts folder not found for theme '{theme_name}'")
return return
font_files = []
for filename in os.listdir(fonts_folder): for filename in os.listdir(fonts_folder):
if filename.lower().endswith((".ttf", ".otf")): if filename.lower().endswith((".ttf", ".otf")):
font_files.append(filename)
# Limit number of fonts loaded to prevent too much blocking
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
for filename in font_files:
if time.time() - start_time > timeout:
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
break
font_path = os.path.join(fonts_folder, filename) font_path = os.path.join(fonts_folder, filename)
font_id = QFontDatabase.addApplicationFont(font_path) font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1: if font_id != -1:
@@ -138,7 +162,14 @@ def load_theme_fonts(theme_name):
else: else:
logger.error(f"Error loading font: {filename}") logger.error(f"Error loading font: {filename}")
# Update the global variable in the main thread
_loaded_theme = theme_name _loaded_theme = theme_name
except Exception as e:
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
# Use QTimer to delay font loading until after the UI is rendered
from PySide6.QtCore import QTimer
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
class ThemeWrapper: class ThemeWrapper:
""" """