modlink_studio.startup
One-shot helpers that run during desktop startup.
These exist as their own module so app.py can stay focused on the
top-to-bottom launch sequence. Everything here is called once per
process: icon loading, the Windows AUMID tag, the splash screen, and
the deferred heavy import chain that lives behind the splash.
1"""One-shot helpers that run during desktop startup. 2 3These exist as their own module so ``app.py`` can stay focused on the 4top-to-bottom launch sequence. Everything here is called once per 5process: icon loading, the Windows AUMID tag, the splash screen, and 6the deferred heavy import chain that lives behind the splash. 7""" 8 9from __future__ import annotations 10 11import sys 12import time 13from importlib.resources import files 14from pathlib import Path 15from threading import Thread 16 17from PyQt6.QtCore import QSize, Qt 18from PyQt6.QtGui import QIcon, QPixmap 19from PyQt6.QtWidgets import QApplication 20 21WINDOWS_APP_USER_MODEL_ID = "ModLink.Studio.Desktop" 22SPLASH_WINDOW_SIZE = QSize(420, 280) 23SPLASH_ICON_SIZE = QSize(112, 112) 24SPLASH_STATUS_TEXT = "正在启动 ModLink Studio..." 25 26 27def load_app_icon() -> QIcon: 28 """Return the packaged ``app_icon.png`` as a :class:`QIcon`. 29 30 Falls back to the repo-local copy under ``assets/`` so editable 31 installs without ``setup.py`` package data still find it. 32 """ 33 try: 34 icon_bytes = files("modlink_studio").joinpath("app_icon.png").read_bytes() 35 except (FileNotFoundError, ModuleNotFoundError, OSError): 36 icon_bytes = None 37 38 if icon_bytes is not None: 39 pixmap = QPixmap() 40 if pixmap.loadFromData(icon_bytes): 41 icon = QIcon() 42 icon.addPixmap(pixmap) 43 return icon 44 45 repo_icon_path = Path(__file__).resolve().parents[3] / "assets" / "app_icon.png" 46 if repo_icon_path.is_file(): 47 return QIcon(str(repo_icon_path)) 48 return QIcon() 49 50 51def set_windows_app_user_model_id() -> None: 52 """Bind the process to our own AUMID so the Windows taskbar uses our icon 53 instead of the cached ``python.exe`` default.""" 54 if sys.platform != "win32": 55 return 56 import ctypes 57 58 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(WINDOWS_APP_USER_MODEL_ID) 59 60 61def show_splash_screen(icon: QIcon): 62 """Display a borderless splash with our icon, a top-left version badge, 63 a status line, and an indeterminate progress bar at the bottom edge.""" 64 from qfluentwidgets import BodyLabel, IndeterminateProgressBar, SplashScreen, Theme, setTheme 65 66 from . import __version__ 67 68 # Apply theme on the main thread before any styled widget is created. 69 # qfluentwidgets installs an application-wide event filter that polishes 70 # styled widgets on the GUI thread; doing setTheme off-thread races with 71 # widget construction and can wipe inline stylesheets. 72 setTheme(Theme.AUTO) 73 74 splash = SplashScreen(icon, parent=None) 75 splash.setWindowFlags(Qt.WindowType.SplashScreen | Qt.WindowType.WindowStaysOnTopHint) 76 splash.titleBar.maxBtn.hide() 77 splash.titleBar.closeBtn.clicked.connect(lambda: sys.exit(0)) 78 splash.setIconSize(SPLASH_ICON_SIZE) 79 splash.resize(SPLASH_WINDOW_SIZE) 80 81 width = splash.width() 82 height = splash.height() 83 side_margin = 12 84 85 # Top-left version badge, mimicking the "Office 2021" pill in Word splash. 86 badge = BodyLabel(f"ModLink Studio {__version__}", splash) 87 badge.setStyleSheet( 88 "QLabel {" 89 " background-color: rgba(0, 120, 212, 0.12);" 90 " color: rgb(0, 90, 158);" 91 " border-radius: 6px;" 92 " padding: 4px 10px;" 93 " font-weight: 600;" 94 "}" 95 ) 96 badge.adjustSize() 97 badge.move(side_margin, side_margin) 98 99 # Status line just above the bottom progress bar. 100 status_label = BodyLabel(SPLASH_STATUS_TEXT, splash) 101 status_label.adjustSize() 102 progress_bar = IndeterminateProgressBar(splash) 103 progress_bar.setFixedHeight(4) 104 progress_bar.setGeometry(0, height - 4, width, 4) 105 progress_bar.start() 106 status_label.move(side_margin, height - 4 - status_label.height() - 12) 107 108 splash._badge = badge # keep references alive 109 splash._status_label = status_label 110 splash._progress_bar = progress_bar 111 112 center = QApplication.primaryScreen().availableGeometry().center() 113 splash.move(center.x() - splash.width() // 2, center.y() - splash.height() // 2) 114 splash.show() 115 _apply_windows_round_corner_preference(splash) 116 QApplication.processEvents() 117 return splash 118 119 120def _apply_windows_round_corner_preference(widget) -> None: 121 """Ask DWM to round the splash corners on Windows 11, matching the main 122 window. Older Windows builds and non-Windows platforms silently fall 123 back to square corners.""" 124 if sys.platform != "win32": 125 return 126 try: 127 import ctypes 128 129 DWMWA_WINDOW_CORNER_PREFERENCE = 33 130 DWMWCP_ROUND = 2 131 preference = ctypes.c_int(DWMWCP_ROUND) 132 ctypes.windll.dwmapi.DwmSetWindowAttribute( 133 ctypes.c_void_p(int(widget.winId())), 134 ctypes.c_uint(DWMWA_WINDOW_CORNER_PREFERENCE), 135 ctypes.byref(preference), 136 ctypes.sizeof(preference), 137 ) 138 except Exception: 139 return 140 141 142def load_runtime_deps() -> tuple[type, type, type]: 143 """Pull in engine, Qt bridge, main window, plus Qt-wide config knobs. 144 145 Imports happen in a background thread so the splash screen animation 146 stays alive. The main thread pumps Qt events at ~60 fps while waiting. 147 """ 148 result: list[object] = [] 149 150 def _import_worker() -> None: 151 import pyqtgraph as pg 152 153 from modlink_core import ModLinkEngine 154 from modlink_ui import MainWindow 155 from modlink_ui.bridge import QtModLinkBridge 156 157 pg.setConfigOptions(useOpenGL=True) 158 result.extend([ModLinkEngine, QtModLinkBridge, MainWindow]) 159 160 thread = Thread(target=_import_worker, name="modlink.startup_import", daemon=True) 161 thread.start() 162 while thread.is_alive(): 163 QApplication.processEvents() 164 time.sleep(0.016) 165 thread.join() 166 167 if len(result) != 3: 168 raise RuntimeError("startup import failed") 169 return result[0], result[1], result[2]
28def load_app_icon() -> QIcon: 29 """Return the packaged ``app_icon.png`` as a :class:`QIcon`. 30 31 Falls back to the repo-local copy under ``assets/`` so editable 32 installs without ``setup.py`` package data still find it. 33 """ 34 try: 35 icon_bytes = files("modlink_studio").joinpath("app_icon.png").read_bytes() 36 except (FileNotFoundError, ModuleNotFoundError, OSError): 37 icon_bytes = None 38 39 if icon_bytes is not None: 40 pixmap = QPixmap() 41 if pixmap.loadFromData(icon_bytes): 42 icon = QIcon() 43 icon.addPixmap(pixmap) 44 return icon 45 46 repo_icon_path = Path(__file__).resolve().parents[3] / "assets" / "app_icon.png" 47 if repo_icon_path.is_file(): 48 return QIcon(str(repo_icon_path)) 49 return QIcon()
Return the packaged app_icon.png as a QIcon.
Falls back to the repo-local copy under assets/ so editable
installs without setup.py package data still find it.
52def set_windows_app_user_model_id() -> None: 53 """Bind the process to our own AUMID so the Windows taskbar uses our icon 54 instead of the cached ``python.exe`` default.""" 55 if sys.platform != "win32": 56 return 57 import ctypes 58 59 ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(WINDOWS_APP_USER_MODEL_ID)
Bind the process to our own AUMID so the Windows taskbar uses our icon
instead of the cached python.exe default.
62def show_splash_screen(icon: QIcon): 63 """Display a borderless splash with our icon, a top-left version badge, 64 a status line, and an indeterminate progress bar at the bottom edge.""" 65 from qfluentwidgets import BodyLabel, IndeterminateProgressBar, SplashScreen, Theme, setTheme 66 67 from . import __version__ 68 69 # Apply theme on the main thread before any styled widget is created. 70 # qfluentwidgets installs an application-wide event filter that polishes 71 # styled widgets on the GUI thread; doing setTheme off-thread races with 72 # widget construction and can wipe inline stylesheets. 73 setTheme(Theme.AUTO) 74 75 splash = SplashScreen(icon, parent=None) 76 splash.setWindowFlags(Qt.WindowType.SplashScreen | Qt.WindowType.WindowStaysOnTopHint) 77 splash.titleBar.maxBtn.hide() 78 splash.titleBar.closeBtn.clicked.connect(lambda: sys.exit(0)) 79 splash.setIconSize(SPLASH_ICON_SIZE) 80 splash.resize(SPLASH_WINDOW_SIZE) 81 82 width = splash.width() 83 height = splash.height() 84 side_margin = 12 85 86 # Top-left version badge, mimicking the "Office 2021" pill in Word splash. 87 badge = BodyLabel(f"ModLink Studio {__version__}", splash) 88 badge.setStyleSheet( 89 "QLabel {" 90 " background-color: rgba(0, 120, 212, 0.12);" 91 " color: rgb(0, 90, 158);" 92 " border-radius: 6px;" 93 " padding: 4px 10px;" 94 " font-weight: 600;" 95 "}" 96 ) 97 badge.adjustSize() 98 badge.move(side_margin, side_margin) 99 100 # Status line just above the bottom progress bar. 101 status_label = BodyLabel(SPLASH_STATUS_TEXT, splash) 102 status_label.adjustSize() 103 progress_bar = IndeterminateProgressBar(splash) 104 progress_bar.setFixedHeight(4) 105 progress_bar.setGeometry(0, height - 4, width, 4) 106 progress_bar.start() 107 status_label.move(side_margin, height - 4 - status_label.height() - 12) 108 109 splash._badge = badge # keep references alive 110 splash._status_label = status_label 111 splash._progress_bar = progress_bar 112 113 center = QApplication.primaryScreen().availableGeometry().center() 114 splash.move(center.x() - splash.width() // 2, center.y() - splash.height() // 2) 115 splash.show() 116 _apply_windows_round_corner_preference(splash) 117 QApplication.processEvents() 118 return splash
Display a borderless splash with our icon, a top-left version badge, a status line, and an indeterminate progress bar at the bottom edge.
143def load_runtime_deps() -> tuple[type, type, type]: 144 """Pull in engine, Qt bridge, main window, plus Qt-wide config knobs. 145 146 Imports happen in a background thread so the splash screen animation 147 stays alive. The main thread pumps Qt events at ~60 fps while waiting. 148 """ 149 result: list[object] = [] 150 151 def _import_worker() -> None: 152 import pyqtgraph as pg 153 154 from modlink_core import ModLinkEngine 155 from modlink_ui import MainWindow 156 from modlink_ui.bridge import QtModLinkBridge 157 158 pg.setConfigOptions(useOpenGL=True) 159 result.extend([ModLinkEngine, QtModLinkBridge, MainWindow]) 160 161 thread = Thread(target=_import_worker, name="modlink.startup_import", daemon=True) 162 thread.start() 163 while thread.is_alive(): 164 QApplication.processEvents() 165 time.sleep(0.016) 166 thread.join() 167 168 if len(result) != 3: 169 raise RuntimeError("startup import failed") 170 return result[0], result[1], result[2]
Pull in engine, Qt bridge, main window, plus Qt-wide config knobs.
Imports happen in a background thread so the splash screen animation stays alive. The main thread pumps Qt events at ~60 fps while waiting.