diff --git a/requirements.txt b/requirements.txt index b9d0351..18dddf6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,33 +7,28 @@ flatbuffers==25.12.19 h11==0.16.0 idna==3.11 lupa==2.8 -mpmath==1.3.0 numpy==2.4.3 onnxruntime==1.24.4 outcome==1.3.0.post0 packaging==26.0 -pefile==2024.8.26 pillow==12.1.1 protobuf==7.34.0 pybrowsers==1.3.2 pycparser==3.0 pyinstaller==6.19.0 -pyinstaller-hooks-contrib==2026.3 PySide6==6.10.2 PySide6_Addons==6.10.2 PySide6_Essentials==6.10.2 PySocks==1.7.1 -pywin32-ctypes==0.2.3 +QtAwesome==1.4.2 +QtPy==2.4.3 requests==2.32.5 selenium==4.38.0 -setuptools==82.0.1 shiboken6==6.10.2 sniffio==1.3.1 sortedcontainers==2.4.0 -sympy==1.14.0 trio==0.33.0 trio-websocket==0.12.2 typing_extensions==4.15.0 urllib3==2.6.3 -websocket-client==1.9.0 -wsproto==1.3.2 +wsproto==1.3.2 \ No newline at end of file diff --git a/src/Main.py b/src/Main.py index c9e50ca..cf35f22 100644 --- a/src/Main.py +++ b/src/Main.py @@ -24,7 +24,6 @@ def main(): translator = QTranslator() if translator.load(":/res/translators/qtbase_zh_CN.ts"): app.installTranslator(translator) - app.setStyle("Fusion") app.setApplicationName("AutoLibrary") if not initializeApp(): sys.exit(-1) diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index 4512368..039a163 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -10,10 +10,16 @@ See the LICENSE file for details. import os from PySide6.QtCore import QStandardPaths, QDir +from PySide6.QtWidgets import QApplication -from managers.log.LogManager import instance as logInstance +from interfaces.ConfigProvider import CfgKey from managers.config.ConfigManager import instance as configInstance from managers.driver.WebDriverManager import instance as webdriverInstance +from managers.log.LogManager import instance as logInstance +from managers.theme.ThemeManager import( + setActiveStyle, + instance as themeInstance +) def _initializeLogManager( @@ -64,13 +70,35 @@ def _initializeWebDriverManager( webdriverInstance(driver_dir) return True +def _initializeAppearance( +): + + app = QApplication.instance() + if not app: + return + cfg = configInstance() + saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") + saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") + saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") + app.setStyle(saved_style) + setActiveStyle(saved_style) + logger = logInstance().getLogger("AppInitializer") + if saved_custom_theme: + try: + themeInstance().applyTheme(saved_custom_theme) + except Exception: + logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme) + themeInstance().clearTheme(saved_theme) + return + themeInstance().clearTheme(saved_theme) + def initializeApp( ) -> bool: """ Initialize the application components Order: - LogManager -> ConfigManager -> WebDriverManager + LogManager -> ConfigManager -> WebDriverManager -> Appearance """ if not _initializeLogManager(): @@ -79,4 +107,5 @@ def initializeApp( return False if not _initializeWebDriverManager(): return False + _initializeAppearance() return True diff --git a/src/gui/ALAutoScriptEditDialog.py b/src/gui/ALAutoScriptEditDialog.py index 750686b..2a32510 100644 --- a/src/gui/ALAutoScriptEditDialog.py +++ b/src/gui/ALAutoScriptEditDialog.py @@ -9,6 +9,8 @@ See the LICENSE file for details. """ from copy import deepcopy +import qtawesome as qta + from PySide6.QtCore import ( QDate, QSize, @@ -20,7 +22,6 @@ from PySide6.QtCore import ( from PySide6.QtGui import ( QColor, QFont, - QIcon, QSyntaxHighlighter, QTextCharFormat, ) @@ -210,23 +211,27 @@ class ALAutoScriptEditDialog(QDialog): Layout.setSpacing(3) Layout.setContentsMargins(3, 3, 3, 3) ToolbarLayout = QHBoxLayout() - self.ZoomInBtn = QPushButton("+") + self.ZoomInBtn = QPushButton("") + self.ZoomInBtn.setIcon(qta.icon("fa6s.plus", color=self._iconColor())) + self.ZoomInBtn.setIconSize(QSize(14, 14)) self.ZoomInBtn.setFixedSize(25, 25) - self.ZoomOutBtn = QPushButton("-") + self.ZoomOutBtn = QPushButton("") + self.ZoomOutBtn.setIcon(qta.icon("fa6s.minus", color=self._iconColor())) + self.ZoomOutBtn.setIconSize(QSize(14, 14)) self.ZoomOutBtn.setFixedSize(25, 25) self.ZoomResetBtn = QPushButton("") - self.ZoomResetBtn.setIcon(QIcon(":/res/icons/Reset.svg")) - self.ZoomResetBtn.setIconSize(QSize(20, 20)) + self.ZoomResetBtn.setIcon(qta.icon("fa6s.rotate-left", color=self._iconColor())) + self.ZoomResetBtn.setIconSize(QSize(14, 14)) self.ZoomResetBtn.setFixedSize(25, 25) self.ZoomResetBtn.setToolTip("重置缩放") self.ZoomLabel = QLabel(f"{self._fontSize}px") self.ZoomLabel.setFixedHeight(25) self.OrchBtn = QPushButton("编排") - self.OrchBtn.setFixedHeight(25) + self.OrchBtn.setFixedSize(80, 25) self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置") ToolbarLayout.addWidget(self.OrchBtn) self.DebugBtn = QPushButton("▶ 调试运行") - self.DebugBtn.setFixedHeight(25) + self.DebugBtn.setFixedSize(80, 25) self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化") ToolbarLayout.addWidget(self.DebugBtn) Sep = QFrame() @@ -240,8 +245,8 @@ class ALAutoScriptEditDialog(QDialog): ToolbarLayout.addWidget(self.ZoomLabel) ToolbarLayout.addStretch() self.CopyBtn = QPushButton("") - self.CopyBtn.setIcon(QIcon(":/res/icons/Copy.svg")) - self.CopyBtn.setIconSize(QSize(20, 20)) + self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor())) + self.CopyBtn.setIconSize(QSize(14, 14)) self.CopyBtn.setFixedSize(25, 25) self.CopyBtn.setToolTip("复制脚本") ToolbarLayout.addWidget(self.CopyBtn) @@ -264,7 +269,9 @@ class ALAutoScriptEditDialog(QDialog): QDialogButtonBox.StandardButton.Cancel ) self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定") + self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setFixedSize(80, 25) self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") + self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setFixedSize(80, 25) Layout.addWidget(self.BtnBox) def createButtonPanel( @@ -537,6 +544,14 @@ class ALAutoScriptEditDialog(QDialog): else: widget.setText(str(value)) + def _iconColor( + self + ) -> str: + + return QApplication.instance().palette().color( + QApplication.instance().palette().ColorRole.WindowText + ).name() + def connectSignals( self ): diff --git a/src/gui/ALConfigWidget.py b/src/gui/ALConfigWidget.py index eea0bb3..e06727b 100644 --- a/src/gui/ALConfigWidget.py +++ b/src/gui/ALConfigWidget.py @@ -42,6 +42,7 @@ from gui.ALUserTreeWidget import ( ALUserTreeWidget ) from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog +from gui.ALWidgetMixin import CenterOnParentMixin from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget from interfaces.ConfigProvider import ( CfgKey, @@ -52,7 +53,7 @@ from utils.JSONReader import JSONReader from utils.JSONWriter import JSONWriter -class ALConfigWidget(QWidget, Ui_ALConfigWidget): +class ALConfigWidget(CenterOnParentMixin, QWidget, Ui_ALConfigWidget): configWidgetIsClosed = Signal() @@ -110,29 +111,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget): self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) - def showEvent( - self, - event - ): - - result = super().showEvent(event) - - screen_rect = self.screen().geometry() - target_pos = self.parent().geometry().center() - target_pos.setX(target_pos.x() - self.width()//2) - target_pos.setY(target_pos.y() - self.height()//2) - if target_pos.x() < 0: - target_pos.setX(0) - if target_pos.x() + self.width() > screen_rect.width(): - target_pos.setX(screen_rect.width() - self.width()) - if target_pos.y() < 0: - target_pos.setY(0) - if target_pos.y() + self.height() > screen_rect.height(): - target_pos.setY(screen_rect.height() - self.height()) - self.move(target_pos) - - return result - def closeEvent( self, event: QCloseEvent diff --git a/src/gui/ALMainWindow.py b/src/gui/ALMainWindow.py index 82a944f..5d805c6 100644 --- a/src/gui/ALMainWindow.py +++ b/src/gui/ALMainWindow.py @@ -33,6 +33,7 @@ from PySide6.QtWidgets import ( from base.MsgBase import MsgBase from gui.ALAboutDialog import ALAboutDialog from gui.ALConfigWidget import ALConfigWidget +from gui.ALSettingsWidget import ALSettingsWidget from gui.ALMainWorkers import ( AutoLibWorker, TimerTaskWorker @@ -60,6 +61,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.__config_paths = ConfigUtils.getAutomationConfigPaths() self.__alTimerTaskManageWidget = None self.__alConfigWidget = None + self.__alSettingsWidget = None self.__auto_lib_thread = None self.__current_timer_task_thread = None self.__is_running_timer_task = False @@ -81,6 +83,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.MessageIOTextEdit.setFont(QFont("Courier New", 10)) self.ManualAction.triggered.connect(self.onManualActionTriggered) self.AboutAction.triggered.connect(self.onAboutActionTriggered) + self.SettingsAction.triggered.connect(self.onSettingsActionTriggered) # initialize timer task widget, but not show it try: @@ -125,7 +128,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): return self.TrayIcon = QSystemTrayIcon(self.Icon, self) self.TrayIcon.setToolTip("AutoLibrary") - self.TrayMenu = QMenu() self.TrayMenu.addAction("显示主窗口", self.showNormal) self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked) @@ -190,6 +192,9 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): if self.__alConfigWidget: self.__alConfigWidget.close() # the config widget is already deleted in the 'self.onConfigWidgetClosed' + if self.__alSettingsWidget: + self.__alSettingsWidget.close() + # the settings widget is already deleted in the 'self.onSettingsWidgetClosed' self._showLog("主窗口关闭") QMainWindow.closeEvent(self, event) @@ -302,6 +307,31 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): self.setControlButtons(True, None, None) self._showLog("配置窗口已关闭,配置文件路径已更新") + @Slot() + def onSettingsWidgetClosed( + self + ): + + if self.__alSettingsWidget: + self.__alSettingsWidget.settingsWidgetIsClosed.disconnect(self.onSettingsWidgetClosed) + self.__alSettingsWidget.deleteLater() + self.__alSettingsWidget = None + self.SettingsAction.setEnabled(True) + + @Slot() + def onSettingsActionTriggered( + self + ): + + if self.__alSettingsWidget is None: + self.__alSettingsWidget = ALSettingsWidget(self) + self.__alSettingsWidget.settingsWidgetIsClosed.connect(self.onSettingsWidgetClosed) + self.__alSettingsWidget.show() + self.__alSettingsWidget.raise_() + self.__alSettingsWidget.activateWindow() + self.SettingsAction.setEnabled(False) + self._showLog("打开全局设置窗口") + @Slot(dict) def onTimerTaskIsReady( self, diff --git a/src/gui/ALSeatMapSelectDialog.py b/src/gui/ALSeatMapSelectDialog.py index 0602dc5..67c812b 100644 --- a/src/gui/ALSeatMapSelectDialog.py +++ b/src/gui/ALSeatMapSelectDialog.py @@ -24,9 +24,9 @@ from PySide6.QtWidgets import ( ) from gui.ALSeatMapView import ALSeatMapView +from gui.ALWidgetMixin import CenterOnParentMixin - -class ALSeatMapSelectDialog(QDialog): +class ALSeatMapSelectDialog(CenterOnParentMixin, QDialog): seatMapSelectDialogIsClosed = Signal(list) @@ -96,29 +96,6 @@ class ALSeatMapSelectDialog(QDialog): self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) - def showEvent( - self, - event - ): - - result = super().showEvent(event) - - screen_rect = self.screen().geometry() - target_pos = self.parent().geometry().center() - target_pos.setX(target_pos.x() - self.width()//2) - target_pos.setY(target_pos.y() - self.height()//2) - if target_pos.x() < 0: - target_pos.setX(0) - if target_pos.x() + self.width() > screen_rect.width(): - target_pos.setX(screen_rect.width() - self.width()) - if target_pos.y() < 0: - target_pos.setY(0) - if target_pos.y() + self.height() > screen_rect.height(): - target_pos.setY(screen_rect.height() - self.height()) - self.move(target_pos) - - return result - def closeEvent( self, event: QCloseEvent diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py new file mode 100644 index 0000000..7dab1f1 --- /dev/null +++ b/src/gui/ALSettingsWidget.py @@ -0,0 +1,433 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import os +import sys + +import qtawesome as qta + +from PySide6.QtCore import ( + QProcess, + Qt, + Signal, + Slot +) +from PySide6.QtGui import ( + QCloseEvent +) +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QStyleFactory, + QWidget +) + +import managers.config.ConfigManager as ConfigManager +from managers.log.LogManager import instance as logInstance +from managers.theme.ThemeManager import( + getActiveStyle, + setActiveStyle, + instance as themeInstance +) + +from gui.ALWidgetMixin import CenterOnParentMixin +from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget +from interfaces.ConfigProvider import ( + CfgKey, + ConfigProvider +) + + +def _applyCustomTheme( + name: str, + fallback_theme: str = "system" +) -> bool: + + if not name: + themeInstance().clearTheme(fallback_theme) + return True + try: + themeInstance().applyTheme(name) + return True + except Exception as e: + logInstance().getLogger("ALSettingsWidget").warning( + f"无法应用自定义主题 '{name}',回退到 {fallback_theme} 外观: {e}" + ) + themeInstance().clearTheme(fallback_theme) + return False + +def _themeToReadable( + need_theme: str +) -> str: + + if need_theme == "dark": + return "深色" + elif need_theme == "light": + return "浅色" + elif need_theme == "both": + return "所有" + else: + return "未知" + +def _restartApp( +): + + QApplication.instance().quit() + QProcess.startDetached(sys.executable, sys.argv) + + +class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): + + settingsWidgetIsClosed = Signal() + + def __init__( + self, + parent=None + ): + super().__init__(parent) + self.__cfg_mgr: ConfigProvider = ConfigManager.instance() + self.__original_theme: str = "" + self.__original_custom_theme: str = "" + self.__original_style: str = "" + + self.setupUi(self) + self.modifyUi() + self.connectSignals() + self.loadSettings() + + def closeEvent( + self, + event: QCloseEvent + ): + + self.settingsWidgetIsClosed.emit() + super().closeEvent(event) + + def modifyUi( + self + ): + + self.setWindowFlags(Qt.WindowType.Window) + self.setNavigationIcons() + color = QApplication.instance().palette().color( + QApplication.instance().palette().ColorRole.WindowText + ).name() + self.ImportCustomThemeButton.setIcon(qta.icon("fa6s.plus", color=color)) + self.ImportCustomThemeButton.setText("") + self.RemoveCustomThemeButton.setIcon(qta.icon("fa6s.minus", color=color)) + self.RemoveCustomThemeButton.setText("") + self.CustomThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) + self.CustomThemeInfoLabel.setStyleSheet( + "border: 1px solid palette(mid);"\ + "border-radius: 2px;"\ + "padding: 5px;" + ) + self.NavigationList.setCurrentRow(0) + self.populateStyles() + self.populateCustomThemes() + + def setNavigationIcons( + self + ): + + app : QApplication | None = QApplication.instance() + color = app.palette().color(app.palette().ColorRole.WindowText).name() + item = self.NavigationList.item(0) + if item: + item.setIcon(qta.icon("fa6s.palette", color=color)) + + def populateStyles( + self + ): + + self.StyleComboBox.clear() + self.StyleComboBox.addItems(QStyleFactory.keys()) + + def populateCustomThemes( + self + ): + + self.CustomThemeComboBox.blockSignals(True) + self.CustomThemeComboBox.clear() + self.CustomThemeComboBox.addItem("默认", "") + self.__theme_cache = {} + themes = themeInstance().listThemes() + for t in themes: + name = t.get("name", "") + file = t.get("file", name) + author = t.get("author", "") + if name: + self.__theme_cache[file] = t + self.CustomThemeComboBox.addItem(name, file) + self.CustomThemeComboBox.blockSignals(False) + + def connectSignals( + self + ): + + self.ImportCustomThemeButton.clicked.connect(self.onImportCustomThemeButtonClicked) + self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked) + self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged) + self.ResetCustomThemeButton.clicked.connect(self.onResetCustomThemeButtonClicked) + self.CancelButton.clicked.connect(self.onCancelButtonClicked) + self.ApplyButton.clicked.connect(self.onApplyButtonClicked) + self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) + + def loadSettings( + self + ): + + theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") + style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") + custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") + self.__original_theme = theme + self.__original_custom_theme = custom_theme + self.__original_style = getActiveStyle() + if theme == "light": + self.LightThemeRadio.setChecked(True) + elif theme == "dark": + self.DarkThemeRadio.setChecked(True) + else: + self.SystemThemeRadio.setChecked(True) + index = self.StyleComboBox.findText(style) + if index < 0: + index = 0 + self.StyleComboBox.setCurrentIndex(index) + if custom_theme: + idx = self.CustomThemeComboBox.findData(custom_theme) + if idx >= 0: + self.CustomThemeComboBox.setCurrentIndex(idx) + self.updateCustomThemeInfo() + self.updateCustomThemeStatus() + + def updateCustomThemeInfo( + self + ): + + file = self.CustomThemeComboBox.currentData() + if not file: + self.CustomThemeInfoLabel.setText("") + return + t = self.__theme_cache.get(file) + if t: + name = t.get("name", "未知") + author = t.get("author", "未知作者") + need_theme = t.get("need_theme", "both") + brief = t.get("brief", "没有相关简介") + self.CustomThemeInfoLabel.setText( + f"{name} - 适用于 {_themeToReadable(need_theme)} 主题
" + f"作者:{author}

" + f"{brief}" + ) + else: + self.CustomThemeInfoLabel.setText("") + + def updateCustomThemeStatus( + self + ): + + file = self.CustomThemeComboBox.currentData() + t = self.__theme_cache.get(file) if file else None + name = t.get("name", "") if t else "" + if name: + self.CustomThemeStatusLabel.setText(f"当前使用 {name} 主题。") + else: + self.CustomThemeStatusLabel.setText("当前使用 默认 主题。") + + def syncRadioFromNeedTheme( + self, + name: str + ): + + t = self.__theme_cache.get(name) + if t: + need_theme = t.get("need_theme", "both") + if need_theme == "light": + self.LightThemeRadio.setChecked(True) + elif need_theme == "dark": + self.DarkThemeRadio.setChecked(True) + + def collectSettings( + self + ): + + if self.LightThemeRadio.isChecked(): + theme = "light" + elif self.DarkThemeRadio.isChecked(): + theme = "dark" + else: + theme = "system" + style = self.StyleComboBox.currentText() + custom_theme = self.CustomThemeComboBox.currentData() or "" + if not custom_theme: + custom_theme = "" + return theme, style, custom_theme + + def saveAndApply( + self + ): + + theme, style, custom_theme = self.collectSettings() + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) + setActiveStyle(style) + if not _applyCustomTheme(custom_theme, theme): + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") + self.syncRadioFromNeedTheme(custom_theme) + # Re-read theme after syncRadioFromNeedTheme — the radio may have + # changed to match the custom theme's need_theme + theme, _, _ = self.collectSettings() + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) + self.setNavigationIcons() + self.updateCustomThemeStatus() + self.updateCustomThemeInfo() + self.__original_theme = theme + self.__original_custom_theme = custom_theme if custom_theme else "" + self.__original_style = getActiveStyle() + + def maybeRestart( + self + ) -> bool: + + reply = QMessageBox.question( + self, + "提示 - AutoLibrary", + "界面风格已修改,需要重启程序才能生效。是否立即重启?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes + ) + if reply == QMessageBox.Yes: + _restartApp() + return True + return False + + @Slot() + def onRemoveCustomThemeButtonClicked( + self + ): + + file = self.CustomThemeComboBox.currentData() + if not file: + QMessageBox.information( + self, + "提示 - AutoLibrary", + "请先选择一个主题。" + ) + return + t = self.__theme_cache.get(file) + name = t.get("name", file) if t else file + reply = QMessageBox.question( + self, + "删除主题 - AutoLibrary", + f"确定要删除主题 \"{name}\" 吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + try: + themeInstance().removeTheme(file) + self.populateCustomThemes() + self.CustomThemeComboBox.setCurrentIndex(0) + self.updateCustomThemeStatus() + self.updateCustomThemeInfo() + except Exception as e: + QMessageBox.warning( + self, + "删除失败 - AutoLibrary", + f"无法删除主题:{e}" + ) + + @Slot() + def onImportCustomThemeButtonClicked( + self + ): + + file_path, _ = QFileDialog.getOpenFileName( + self, + "导入主题 - AutoLibrary", + "", + "主题文件 (*.altheme *.qss);;所有文件 (*)" + ) + if not file_path: + return + try: + file_id = themeInstance().importTheme(file_path) + self.populateCustomThemes() + idx = self.CustomThemeComboBox.findData(file_id) + if idx >= 0: + self.CustomThemeComboBox.setCurrentIndex(idx) + self.updateCustomThemeStatus() + self.updateCustomThemeInfo() + except Exception as e: + QMessageBox.warning( + self, + "导入失败 - AutoLibrary", + f"无法导入主题文件:{e}" + ) + + @Slot() + def onCustomThemeComboBoxChanged( + self, + index: int + ): + + self.updateCustomThemeInfo() + # no status update, because custom theme is not applied yet. + + @Slot() + def onResetCustomThemeButtonClicked( + self + ): + + self.CustomThemeComboBox.blockSignals(True) + if self.__original_custom_theme: + idx = self.CustomThemeComboBox.findData(self.__original_custom_theme) + if idx >= 0: + self.CustomThemeComboBox.setCurrentIndex(idx) + else: + self.CustomThemeComboBox.setCurrentIndex(0) + else: + self.CustomThemeComboBox.setCurrentIndex(0) + self.CustomThemeComboBox.blockSignals(False) + if self.__original_theme == "light": + self.LightThemeRadio.setChecked(True) + elif self.__original_theme == "dark": + self.DarkThemeRadio.setChecked(True) + else: + self.SystemThemeRadio.setChecked(True) + self.updateCustomThemeInfo() + self.updateCustomThemeStatus() + + @Slot() + def onCancelButtonClicked( + self + ): + + self.close() + + @Slot() + def onApplyButtonClicked( + self + ): + + _, style, _ = self.collectSettings() + style_changed = self.__original_style != style + self.saveAndApply() + if style_changed: + self.maybeRestart() + + @Slot() + def onConfirmButtonClicked( + self + ): + + self.onApplyButtonClicked() # virtually call apply button clicked + self.close() diff --git a/src/gui/ALTimerTaskManageWidget.py b/src/gui/ALTimerTaskManageWidget.py index c6e191f..8b9ac30 100644 --- a/src/gui/ALTimerTaskManageWidget.py +++ b/src/gui/ALTimerTaskManageWidget.py @@ -43,6 +43,7 @@ from gui.ALTimerTaskAddDialog import ( ALTimerTaskStatus ) from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog +from gui.ALWidgetMixin import CenterOnParentMixin from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget from interfaces.ConfigProvider import ( CfgKey, @@ -189,7 +190,7 @@ class ALTimerTaskItemWidget(QWidget): Menu.exec(self.mapToGlobal(pos)) -class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): +class ALTimerTaskManageWidget(CenterOnParentMixin, QWidget, Ui_ALTimerTaskManageWidget): class SortPolicy(Enum): @@ -299,29 +300,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): ) return False - def showEvent( - self, - event - ): - - result = super().showEvent(event) - - screen_rect = self.screen().geometry() - target_pos = self.parent().geometry().center() - target_pos.setX(target_pos.x() - self.width()//2) - target_pos.setY(target_pos.y() - self.height()//2) - if target_pos.x() < 0: - target_pos.setX(0) - if target_pos.x() + self.width() > screen_rect.width(): - target_pos.setX(screen_rect.width() - self.width()) - if target_pos.y() < 0: - target_pos.setY(0) - if target_pos.y() + self.height() > screen_rect.height(): - target_pos.setY(screen_rect.height() - self.height()) - self.move(target_pos) - - return result - def closeEvent( self, event: QCloseEvent diff --git a/src/gui/ALWebDriverDownloadDialog.py b/src/gui/ALWebDriverDownloadDialog.py index 42ac710..aaf619e 100644 --- a/src/gui/ALWebDriverDownloadDialog.py +++ b/src/gui/ALWebDriverDownloadDialog.py @@ -38,6 +38,7 @@ from managers.driver.WebDriverManager import ( WebDriverStatus ) from gui.ALStatusLabel import ALStatusLabel +from gui.ALWidgetMixin import CenterOnParentMixin class DownloadWorker(QThread): @@ -123,7 +124,7 @@ class DownloadWorker(QThread): self.wait() -class ALWebDriverDownloadDialog(QDialog): +class ALWebDriverDownloadDialog(CenterOnParentMixin, QDialog): def __init__( self, @@ -152,28 +153,6 @@ class ALWebDriverDownloadDialog(QDialog): self.initializeDriverManager() self.refreshDriverList() - def showEvent( - self, - event - ): - - result = super().showEvent(event) - if self.parent(): - screen_rect = self.screen().geometry() - target_pos = self.parent().geometry().center() - target_pos.setX(target_pos.x() - self.width()//2) - target_pos.setY(target_pos.y() - self.height()//2) - if target_pos.x() < 0: - target_pos.setX(0) - if target_pos.x() + self.width() > screen_rect.width(): - target_pos.setX(screen_rect.width() - self.width()) - if target_pos.y() < 0: - target_pos.setY(0) - if target_pos.y() + self.height() > screen_rect.height(): - target_pos.setY(screen_rect.height() - self.height()) - self.move(target_pos) - return result - def setupUi( self ): diff --git a/src/gui/ALWidgetMixin.py b/src/gui/ALWidgetMixin.py new file mode 100644 index 0000000..0e95ebf --- /dev/null +++ b/src/gui/ALWidgetMixin.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +from PySide6.QtGui import QShowEvent + + +class CenterOnParentMixin: + """ + Mixin that centres the widget relative to its parent on first show, + clamping the position to the screen bounds. + + Usage:: + + class MyWidget(CenterOnParentMixin, QWidget, Ui_MyWidget): + pass + + class MyDialog(CenterOnParentMixin, QDialog): + pass + + The mixin must appear **before** QWidget / QDialog in the base list + so that ``super().showEvent(event)`` resolves up the MRO correctly. + """ + + def showEvent( + self, + event: QShowEvent + ): + + super().showEvent(event) + if self.parent(): + screen_rect = self.screen().geometry() + target_pos = self.parent().geometry().center() + target_pos.setX(target_pos.x() - self.width() // 2) + target_pos.setY(target_pos.y() - self.height() // 2) + if target_pos.x() < 0: + target_pos.setX(0) + if target_pos.x() + self.width() > screen_rect.width(): + target_pos.setX(screen_rect.width() - self.width()) + if target_pos.y() < 0: + target_pos.setY(0) + if target_pos.y() + self.height() > screen_rect.height(): + target_pos.setY(screen_rect.height() - self.height()) + self.move(target_pos) diff --git a/src/gui/resources/themes/BlueForest.qss b/src/gui/resources/themes/BlueForest.qss new file mode 100644 index 0000000..0e6db26 --- /dev/null +++ b/src/gui/resources/themes/BlueForest.qss @@ -0,0 +1,539 @@ +/* + * Copyright (c) 2026 KenanZhu. + * All rights reserved. + * + * This software is provided "as is", without any warranty of any kind. + * You may use, modify, and distribute this file under the terms of the MIT License. + * See the LICENSE file for details. + * + * + * AutoLibrary Official Theme : BlueForest + */ + +/* ---- Global ---- */ +QMainWindow::separator { + background-color: #1c2840; + width: 1px; + height: 1px; +} + +/* ---- Menu Bar ---- */ +QMenuBar { + background-color: #0f1628; + border-bottom: 1px solid #1c2840; + padding: 2px 5px; + color: #d0daf0; +} +QMenuBar::item { + padding: 2px 10px; + border-radius: 4px; +} +QMenuBar::item:selected { + background-color: #1c2840; +} +QMenu { + background-color: #162038; + border-style: solid; + border-color: #253250; + border-width: 1px; + padding: 4px; + border-radius: 6px; +} +QMenu::item { + padding: 5px 15px 5px 10px; + border-radius: 4px; +} +QMenu::item:selected { + background-color: #2dd4bf; + color: #0f1119; +} +QMenu::separator { + height: 1px; + background-color: #253250; + margin: 4px 8px; +} + +/* ---- Button ---- */ +QPushButton { + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; + color: #d0daf0; + padding: 4px 12px; + background-color: #1c2840; +} +QPushButton:hover { + background-color: #243458; + border-color: #334478; +} +QPushButton:pressed { + background-color: #162038; + border-color: #2dd4bf; +} +QPushButton:disabled { + background-color: #162038; + color: #5568a0; + border-color: #1c2840; +} +QPushButton[default="true"] { + background-color: #2dd4bf; + color: #0f1119; + border-color: #2dd4bf; +} +QPushButton[default="true"]:hover { + background-color: #3de0cc; +} + +/* ---- Input ---- */ +QLineEdit, +QPlainTextEdit, +QTextEdit, +QSpinBox, +QDoubleSpinBox, +QDateEdit, +QTimeEdit { + background-color: #0a1020; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; + padding: 4px 8px; + color: #d0daf0; + selection-background-color: #2dd4bf; + selection-color: #0f1119; +} +QLineEdit:focus, +QPlainTextEdit:focus, +QTextEdit:focus, +QSpinBox:focus, +QDoubleSpinBox:focus, +QDateEdit:focus, +QTimeEdit:focus { + border-color: #2dd4bf; +} +QPlainTextEdit, +QTextEdit { + background-color: #0a1020; +} +QLineEdit:disabled, +QPlainTextEdit:disabled, +QTextEdit:disabled, +QSpinBox:disabled, +QDoubleSpinBox:disabled, +QDateEdit:disabled, +QTimeEdit:disabled { + background-color: #162038; + color: #5568a0; + border-color: #1c2840; +} + +/* ---- Spin Button Arrows ---- */ +QSpinBox::up-button, +QDoubleSpinBox::up-button, +QDateEdit::up-button, +QTimeEdit::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 10px; + border-left: 1px solid #253250; + border-bottom: 1px solid #253250; + border-top-right-radius: 4px; +} +QSpinBox::up-button:hover, +QDoubleSpinBox::up-button:hover, +QDateEdit::up-button:hover, +QTimeEdit::up-button:hover { + background-color: #1c2840; +} +QSpinBox::up-arrow, +QDoubleSpinBox::up-arrow, +QDateEdit::up-arrow, +QTimeEdit::up-arrow { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid #7888b8; + margin-top: 2px; +} +QSpinBox::down-button, +QDoubleSpinBox::down-button, +QDateEdit::down-button, +QTimeEdit::down-button { + width: 10px; + subcontrol-origin: border; + subcontrol-position: bottom right; + border-left: 1px solid #253250; + border-bottom-right-radius: 4px; +} +QSpinBox::down-button:hover, +QDoubleSpinBox::down-button:hover, +QDateEdit::down-button:hover, +QTimeEdit::down-button:hover { + background-color: #1c2840; +} +QSpinBox::down-arrow, +QDoubleSpinBox::down-arrow, +QDateEdit::down-arrow, +QTimeEdit::down-arrow { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #7888b8; + margin-bottom: 2px; +} +QSpinBox::up-button:disabled, +QDoubleSpinBox::up-button:disabled, +QDateEdit::up-button:disabled, +QTimeEdit::up-button:disabled, +QSpinBox::down-button:disabled, +QDoubleSpinBox::down-button:disabled, +QDateEdit::down-button:disabled, +QTimeEdit::down-button:disabled { + background-color: #162038; +} +QSpinBox::up-arrow:disabled, +QDoubleSpinBox::up-arrow:disabled, +QDateEdit::up-arrow:disabled, +QTimeEdit::up-arrow:disabled { + border-bottom-color: #5568a0; +} +QSpinBox::down-arrow:disabled, +QDoubleSpinBox::down-arrow:disabled, +QDateEdit::down-arrow:disabled, +QTimeEdit::down-arrow:disabled { + border-top-color: #5568a0; +} + +/* ---- Combo Box ---- */ +QComboBox { + background-color: #1c2840; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; + padding: 4px 10px; + color: #d0daf0; +} +QComboBox:hover { + border-color: #334478; +} +QComboBox:focus { + border-color: #2dd4bf; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #253250; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} +QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 6px solid #7888b8; + margin-right: 6px; +} +QComboBox QAbstractItemView { + background-color: #162038; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 4px; + selection-background-color: #2dd4bf; + selection-color: #0f1119; + outline: none; +} +QComboBox:disabled { + background-color: #162038; + color: #5568a0; + border-color: #1c2840; +} + +/* ---- Check Box / Radio Button ---- */ +QCheckBox, +QRadioButton { + spacing: 5px; + color: #d0daf0; +} +QCheckBox::indicator, +QRadioButton::indicator { + border-style: solid; + border-color: #334478; + border-width: 2px; + background-color: #0a1020; +} +QCheckBox::indicator { + border-radius: 3px; +} +QRadioButton::indicator { + border-radius: 7px; +} +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + border-color: #2dd4bf; +} +QCheckBox::indicator:checked { + background-color: #2dd4bf; + border-color: #2dd4bf; +} +QRadioButton::indicator:checked { + background-color: #2dd4bf; + border-color: #2dd4bf; +} +QCheckBox::indicator:disabled, +QRadioButton::indicator:disabled { + border-color: #253250; + background-color: #162038; +} +QCheckBox::indicator:checked:hover, +QRadioButton::indicator:checked:hover { + border-color: #a0f0e8; +} + +/* Tree / List / Table Widget CheckBox Indicator */ +QTreeWidget::indicator, +QListWidget::indicator, +QTableWidget::indicator { + border: 2px solid #5568a0; + border-radius: 3px; + background-color: #162038; +} +QTreeWidget::indicator:hover, +QListWidget::indicator:hover, +QTableWidget::indicator:hover { + border-color: #a0f0e8; +} +QTreeWidget::indicator:checked, +QListWidget::indicator:checked, +QTableWidget::indicator:checked { + background-color: #2dd4bf; + border-color: #2dd4bf; +} +QTreeWidget::indicator:checked:hover, +QListWidget::indicator:checked:hover, +QTableWidget::indicator:checked:hover { + border-color: #a0f0e8; +} +QTreeWidget::indicator:disabled, +QListWidget::indicator:disabled, +QTableWidget::indicator:disabled { + background-color: #1c2840; + border-color: #334478; +} +QTreeWidget::indicator:indeterminate, +QListWidget::indicator:indeterminate, +QTableWidget::indicator:indeterminate { + background-color: #2dd4bf; + border-color: #a0f0e8; +} + +/* ---- Group Box ---- */ +QGroupBox { + margin-top: 5px; + padding-top: 15px; + color: #b4c2f5; + font-weight: bold; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; +} + +/* ---- Tab ---- */ +QTabWidget::pane { + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; + background-color: #0f1a2e; + top: -1px; +} +QTabBar::tab { + background-color: #162038; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-bottom: none; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + padding: 6px 16px; + margin-right: 2px; + color: #7888b8; +} +QTabBar::tab:selected { + background-color: #0f1a2e; + color: #2dd4bf; + border-bottom: 2px solid #2dd4bf; +} + +/* ---- List / Tree ---- */ +QListWidget, +QTreeWidget, +QTableWidget { + background-color: #0a1020; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; + outline: none; + color: #d0daf0; + alternate-background-color: #101c30; +} +QListWidget::item, +QTreeWidget::item, +QTableWidget::item { + padding: 5px 5px; +} +QHeaderView::section { + background-color: #0f1628; + border-right: 1px solid #253250; + border-bottom: 1px solid #253250; + padding: 5px 10px; + color: #8b9ad0; + font-weight: bold; +} + +/* ---- Scroll Bar ---- */ +QScrollBar:vertical { + background-color: #0f1a2e; + width: 10px; + border-radius: 5px; +} +QScrollBar::handle:vertical { + background-color: #334478; + min-height: 30px; + border-radius: 5px; +} +QScrollBar::handle:vertical:hover { + background-color: #5568a0; +} +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + height: 0; +} +QScrollBar:horizontal { + background-color: #0f1a2e; + height: 10px; + border-radius: 5px; +} +QScrollBar::handle:horizontal { + background-color: #334478; + min-width: 30px; + border-radius: 5px; +} +QScrollBar::handle:horizontal:hover { + background-color: #5568a0; +} +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal { + width: 0; +} + +/* ---- Progress Bar ---- */ +QProgressBar { + background-color: #0a1020; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 5px; + height: 10px; + text-align: center; + color: #d0daf0; +} +QProgressBar::chunk { + background-color: #2dd4bf; + border-radius: 4px; +} + +/* ---- Slider ---- */ +QSlider::groove:horizontal { + background-color: #1c2840; + height: 6px; + border-radius: 3px; +} +QSlider::handle:horizontal { + background-color: #2dd4bf; + width: 16px; + height: 16px; + margin: -5px 0; + border-radius: 8px; +} +QSlider::sub-page:horizontal { + background-color: #2dd4bf; + border-radius: 3px; +} +QSlider::handle:horizontal:disabled { + background-color: #5568a0; +} +QSlider::sub-page:horizontal:disabled { + background-color: #5568a0; +} + +/* ---- Tool Tip ---- */ +QToolTip { + background-color: #1c2840; + border-style: solid; + border-color: #2dd4bf; + border-width: 1px; + border-radius: 4px; + padding: 4px 8px; + color: #d0daf0; +} + +/* ---- Status Bar ---- */ +QStatusBar { + background-color: #0f1628; + border-top: 1px solid #1c2840; + color: #7888b8; +} + +/* ---- Splitter ---- */ +QSplitter::handle { + background-color: #253250; + margin: 1px; +} +QSplitter::handle:horizontal { + width: 2px; +} +QSplitter::handle:vertical { + height: 2px; +} + +/* ---- Dialog ---- */ +QDialog { + background-color: #0f1a2e; +} + +/* ---- Date / Time Editor Drop-down ---- */ +QDateEdit::drop-down, +QTimeEdit::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #253250; +} +QCalendarWidget { + background-color: #162038; + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 6px; +} +QCalendarWidget QToolButton { + color: #d0daf0; + border-radius: 4px; + padding: 4px 8px; +} +QCalendarWidget QToolButton:hover { + background-color: #1c2840; +} +QCalendarWidget QMenu { + background-color: #162038; +} + +/* ---- Frame ---- */ +QFrame[frameShape="4"], /* HLine */ +QFrame[frameShape="5"] /* VLine */ { + background-color: #253250; +} diff --git a/src/gui/resources/themes/LightLake.qss b/src/gui/resources/themes/LightLake.qss new file mode 100644 index 0000000..2ea0239 --- /dev/null +++ b/src/gui/resources/themes/LightLake.qss @@ -0,0 +1,539 @@ +/* + * Copyright (c) 2026 KenanZhu. + * All rights reserved. + * + * This software is provided "as is", without any warranty of any kind. + * You may use, modify, and distribute this file under the terms of the MIT License. + * See the LICENSE file for details. + * + * + * AutoLibrary Official Theme : LightLake + */ + +/* ---- Global ---- */ +QMainWindow::separator { + background-color: #c0cdda; + width: 1px; + height: 1px; +} + +/* ---- Menu Bar ---- */ +QMenuBar { + background-color: #dce4ee; + border-bottom: 1px solid #c0cdda; + padding: 2px 5px; + color: #1a2740; +} +QMenuBar::item { + padding: 2px 10px; + border-radius: 4px; +} +QMenuBar::item:selected { + background-color: #d5dde8; +} +QMenu { + background-color: #ffffff; + border-style: solid; + border-color: #d0d8e4; + border-width: 1px; + padding: 4px; + border-radius: 6px; +} +QMenu::item { + padding: 5px 15px 5px 10px; + border-radius: 4px; +} +QMenu::item:selected { + background-color: #0ea58a; + color: #ffffff; +} +QMenu::separator { + height: 1px; + background-color: #d0d8e4; + margin: 4px 8px; +} + +/* ---- Button ---- */ +QPushButton { + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + color: #1a2740; + padding: 4px 12px; + background-color: #d5dde8; +} +QPushButton:hover { + background-color: #c8d4e2; + border-color: #90a4c4; +} +QPushButton:pressed { + background-color: #e2e8f0; + border-color: #0ea58a; +} +QPushButton:disabled { + background-color: #e8ecf2; + color: #98a8c0; + border-color: #d5dde8; +} +QPushButton[default="true"] { + background-color: #0ea58a; + color: #ffffff; + border-color: #0ea58a; +} +QPushButton[default="true"]:hover { + background-color: #14c7a4; +} + +/* ---- Input ---- */ +QLineEdit, +QPlainTextEdit, +QTextEdit, +QSpinBox, +QDoubleSpinBox, +QDateEdit, +QTimeEdit { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + padding: 4px 8px; + color: #1a2740; + selection-background-color: #0ea58a; + selection-color: #ffffff; +} +QLineEdit:focus, +QPlainTextEdit:focus, +QTextEdit:focus, +QSpinBox:focus, +QDoubleSpinBox:focus, +QDateEdit:focus, +QTimeEdit:focus { + border-color: #0ea58a; +} +QPlainTextEdit, +QTextEdit { + background-color: #ffffff; +} +QLineEdit:disabled, +QPlainTextEdit:disabled, +QTextEdit:disabled, +QSpinBox:disabled, +QDoubleSpinBox:disabled, +QDateEdit:disabled, +QTimeEdit:disabled { + background-color: #e8ecf2; + color: #98a8c0; + border-color: #d5dde8; +} + +/* ---- Spin Button Arrows ---- */ +QSpinBox::up-button, +QDoubleSpinBox::up-button, +QDateEdit::up-button, +QTimeEdit::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 10px; + border-left: 1px solid #c0cdda; + border-bottom: 1px solid #c0cdda; + border-top-right-radius: 4px; +} +QSpinBox::up-button:hover, +QDoubleSpinBox::up-button:hover, +QDateEdit::up-button:hover, +QTimeEdit::up-button:hover { + background-color: #d5dde8; +} +QSpinBox::up-arrow, +QDoubleSpinBox::up-arrow, +QDateEdit::up-arrow, +QTimeEdit::up-arrow { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 5px solid #6a7898; + margin-top: 2px; +} +QSpinBox::down-button, +QDoubleSpinBox::down-button, +QDateEdit::down-button, +QTimeEdit::down-button { + width: 10px; + subcontrol-origin: border; + subcontrol-position: bottom right; + border-left: 1px solid #c0cdda; + border-bottom-right-radius: 4px; +} +QSpinBox::down-button:hover, +QDoubleSpinBox::down-button:hover, +QDateEdit::down-button:hover, +QTimeEdit::down-button:hover { + background-color: #d5dde8; +} +QSpinBox::down-arrow, +QDoubleSpinBox::down-arrow, +QDateEdit::down-arrow, +QTimeEdit::down-arrow { + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #6a7898; + margin-bottom: 2px; +} +QSpinBox::up-button:disabled, +QDoubleSpinBox::up-button:disabled, +QDateEdit::up-button:disabled, +QTimeEdit::up-button:disabled, +QSpinBox::down-button:disabled, +QDoubleSpinBox::down-button:disabled, +QDateEdit::down-button:disabled, +QTimeEdit::down-button:disabled { + background-color: #e8ecf2; +} +QSpinBox::up-arrow:disabled, +QDoubleSpinBox::up-arrow:disabled, +QDateEdit::up-arrow:disabled, +QTimeEdit::up-arrow:disabled { + border-bottom-color: #98a8c0; +} +QSpinBox::down-arrow:disabled, +QDoubleSpinBox::down-arrow:disabled, +QDateEdit::down-arrow:disabled, +QTimeEdit::down-arrow:disabled { + border-top-color: #98a8c0; +} + +/* ---- Combo Box ---- */ +QComboBox { + background-color: #d5dde8; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + padding: 4px 10px; + color: #1a2740; +} +QComboBox:hover { + border-color: #90a4c4; +} +QComboBox:focus { + border-color: #0ea58a; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #c0cdda; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} +QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 6px solid #6a7898; + margin-right: 6px; +} +QComboBox QAbstractItemView { + background-color: #ffffff; + border-style: solid; + border-color: #d0d8e4; + border-width: 1px; + border-radius: 4px; + selection-background-color: #0ea58a; + selection-color: #ffffff; + outline: none; +} +QComboBox:disabled { + background-color: #e8ecf2; + color: #98a8c0; + border-color: #d5dde8; +} + +/* ---- Check Box / Radio Button ---- */ +QCheckBox, +QRadioButton { + spacing: 5px; + color: #1a2740; +} +QCheckBox::indicator, +QRadioButton::indicator { + border-style: solid; + border-color: #90a4c4; + border-width: 2px; + background-color: #ffffff; +} +QCheckBox::indicator { + border-radius: 3px; +} +QRadioButton::indicator { + border-radius: 7px; +} +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + border-color: #0ea58a; +} +QCheckBox::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QRadioButton::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QCheckBox::indicator:disabled, +QRadioButton::indicator:disabled { + border-color: #c0cdda; + background-color: #e8ecf2; +} +QCheckBox::indicator:checked:hover, +QRadioButton::indicator:checked:hover { + border-color: #14c7a4; +} + +/* Tree / List / Table Widget CheckBox Indicator */ +QTreeWidget::indicator, +QListWidget::indicator, +QTableWidget::indicator { + border: 2px solid #a0b4cc; + border-radius: 3px; + background-color: #e8ecf2; +} +QTreeWidget::indicator:hover, +QListWidget::indicator:hover, +QTableWidget::indicator:hover { + border-color: #14c7a4; +} +QTreeWidget::indicator:checked, +QListWidget::indicator:checked, +QTableWidget::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QTreeWidget::indicator:checked:hover, +QListWidget::indicator:checked:hover, +QTableWidget::indicator:checked:hover { + border-color: #14c7a4; +} +QTreeWidget::indicator:disabled, +QListWidget::indicator:disabled, +QTableWidget::indicator:disabled { + background-color: #d5dde8; + border-color: #c0cdda; +} +QTreeWidget::indicator:indeterminate, +QListWidget::indicator:indeterminate, +QTableWidget::indicator:indeterminate { + background-color: #0ea58a; + border-color: #14c7a4; +} + +/* ---- Group Box ---- */ +QGroupBox { + margin-top: 5px; + padding-top: 15px; + color: #4a6080; + font-weight: bold; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; +} + +/* ---- Tab ---- */ +QTabWidget::pane { + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + background-color: #f0f4f8; + top: -1px; +} +QTabBar::tab { + background-color: #e0e6ee; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-bottom: none; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + padding: 6px 16px; + margin-right: 2px; + color: #6a7898; +} +QTabBar::tab:selected { + background-color: #f0f4f8; + color: #0ea58a; + border-bottom: 2px solid #0ea58a; +} + +/* ---- List / Tree ---- */ +QListWidget, +QTreeWidget, +QTableWidget { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + outline: none; + color: #1a2740; + alternate-background-color: #f4f7fa; +} +QListWidget::item, +QTreeWidget::item, +QTableWidget::item { + padding: 5px 5px; +} +QHeaderView::section { + background-color: #dce4ee; + border-right: 1px solid #c0cdda; + border-bottom: 1px solid #c0cdda; + padding: 5px 10px; + color: #4a6080; + font-weight: bold; +} + +/* ---- Scroll Bar ---- */ +QScrollBar:vertical { + background-color: #eef2f6; + width: 10px; + border-radius: 5px; +} +QScrollBar::handle:vertical { + background-color: #a0b4cc; + min-height: 30px; + border-radius: 5px; +} +QScrollBar::handle:vertical:hover { + background-color: #8098b8; +} +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + height: 0; +} +QScrollBar:horizontal { + background-color: #eef2f6; + height: 10px; + border-radius: 5px; +} +QScrollBar::handle:horizontal { + background-color: #a0b4cc; + min-width: 30px; + border-radius: 5px; +} +QScrollBar::handle:horizontal:hover { + background-color: #8098b8; +} +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal { + width: 0; +} + +/* ---- Progress Bar ---- */ +QProgressBar { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + height: 10px; + text-align: center; + color: #1a2740; +} +QProgressBar::chunk { + background-color: #0ea58a; + border-radius: 4px; +} + +/* ---- Slider ---- */ +QSlider::groove:horizontal { + background-color: #d5dde8; + height: 6px; + border-radius: 3px; +} +QSlider::handle:horizontal { + background-color: #0ea58a; + width: 16px; + height: 16px; + margin: -5px 0; + border-radius: 8px; +} +QSlider::sub-page:horizontal { + background-color: #0ea58a; + border-radius: 3px; +} +QSlider::handle:horizontal:disabled { + background-color: #98a8c0; +} +QSlider::sub-page:horizontal:disabled { + background-color: #98a8c0; +} + +/* ---- Tool Tip ---- */ +QToolTip { + background-color: #d5dde8; + border-style: solid; + border-color: #0ea58a; + border-width: 1px; + border-radius: 4px; + padding: 4px 8px; + color: #1a2740; +} + +/* ---- Status Bar ---- */ +QStatusBar { + background-color: #e8ecf2; + border-top: 1px solid #c0cdda; + color: #6a7898; +} + +/* ---- Splitter ---- */ +QSplitter::handle { + background-color: #c0cdda; + margin: 1px; +} +QSplitter::handle:horizontal { + width: 2px; +} +QSplitter::handle:vertical { + height: 2px; +} + +/* ---- Dialog ---- */ +QDialog { + background-color: #f0f4f8; +} + +/* ---- Date / Time Editor Drop-down ---- */ +QDateEdit::drop-down, +QTimeEdit::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #c0cdda; +} +QCalendarWidget { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 6px; +} +QCalendarWidget QToolButton { + color: #1a2740; + border-radius: 4px; + padding: 4px 8px; +} +QCalendarWidget QToolButton:hover { + background-color: #d5dde8; +} +QCalendarWidget QMenu { + background-color: #ffffff; +} + +/* ---- Frame ---- */ +QFrame[frameShape="4"], /* HLine */ +QFrame[frameShape="5"] /* VLine */ { + background-color: #c0cdda; +} diff --git a/src/gui/resources/ui/ALConfigWidget.ui b/src/gui/resources/ui/ALConfigWidget.ui index a272576..58db0b8 100644 --- a/src/gui/resources/ui/ALConfigWidget.ui +++ b/src/gui/resources/ui/ALConfigWidget.ui @@ -1956,13 +1956,13 @@ - 100 + 120 25 - 100 + 120 25 diff --git a/src/gui/resources/ui/ALMainWindow.ui b/src/gui/resources/ui/ALMainWindow.ui index c210a8b..84aa38c 100644 --- a/src/gui/resources/ui/ALMainWindow.ui +++ b/src/gui/resources/ui/ALMainWindow.ui @@ -281,6 +281,12 @@ font: 700 9pt; true + + + 工具 + + + true @@ -291,6 +297,7 @@ font: 700 9pt; + @@ -308,6 +315,11 @@ font: 700 9pt; 关于 + + + 全局设置 + + diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui new file mode 100644 index 0000000..45ed0e8 --- /dev/null +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -0,0 +1,539 @@ + + + ALSettingsWidget + + + + 0 + 0 + 520 + 420 + + + + + 480 + 420 + + + + + 580 + 420 + + + + 全局设置 - AutoLibrary + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + Qt::FocusPolicy::StrongFocus + + + QFrame::Shape::NoFrame + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + QAbstractItemView::SelectionMode::SingleSelection + + + + 20 + 20 + + + + 0 + + + + 外观 + + + + + + + + + + + QFrame::Shape::NoFrame + + + true + + + + + 0 + -51 + 397 + 434 + + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 主题模式 + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 浅色 + + + + + + + 深色 + + + + + + + 跟随系统 + + + true + + + + + + + + + + 界面风格 + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 5 + + + + + + 100 + 25 + + + + + 100 + 25 + + + + 应用程序样式: + + + + + + + + 160 + 25 + + + + + + + + + + 更改样式将在下次启动应用程序时生效。 + + + + + + + + + + 自定义外观 + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 选择一个主题,或导入新的主题文件: + + + true + + + + + + + 5 + + + + + + 160 + 25 + + + + + + + + + 0 + 25 + + + + false + + + 选择或输入 QSS 样式表文件路径... + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + + + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + - + + + + + + + + + + 0 + 60 + + + + + + + Qt::TextFormat::RichText + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + true + + + + + + + 5 + + + + + + 80 + 25 + + + + 重置主题 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + 当前使用程序 默认 外观。 + + + true + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 80 + 25 + + + + + 80 + 25 + + + + 取消 + + + + + + + + 80 + 25 + + + + + 80 + 25 + + + + 应用 + + + + + + + + 80 + 25 + + + + + 80 + 25 + + + + 确认 + + + true + + + + + + + + + + diff --git a/src/gui/resources/ui/ALTimerTaskAddDialog.ui b/src/gui/resources/ui/ALTimerTaskAddDialog.ui index e2fc6c4..251a4e2 100644 --- a/src/gui/resources/ui/ALTimerTaskAddDialog.ui +++ b/src/gui/resources/ui/ALTimerTaskAddDialog.ui @@ -7,13 +7,13 @@ 0 0 350 - 400 + 500 350 - 460 + 500 diff --git a/src/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py index 9ee5bb4..d4f180a 100644 --- a/src/interfaces/ConfigProvider.py +++ b/src/interfaces/ConfigProvider.py @@ -66,6 +66,12 @@ class CfgKey: CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current") PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths") + class APPEARANCE: + ROOT = ConfigPath(ConfigType.GLOBAL, "appearance") + THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme") + STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style") + CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme") + class TIMERTASK: ROOT = ConfigPath(ConfigType.TIMERTASK, "") TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks") diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py index 4f89eb4..0f54f13 100644 --- a/src/managers/config/ConfigManager.py +++ b/src/managers/config/ConfigManager.py @@ -54,6 +54,11 @@ class ConfigTemplate: "current": 0, "paths": [] } + }, + "appearance": { + "theme": "system", + "style": "Fusion", + "custom_theme": "" } } case ConfigType.BULLETIN: diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py new file mode 100644 index 0000000..755c67b --- /dev/null +++ b/src/managers/theme/ThemeManager.py @@ -0,0 +1,355 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import os +import shutil +import threading + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QApplication, + QStyleFactory +) + +from interfaces.ConfigProvider import CfgKey +from managers.config.ConfigManager import instance as configInstance +from managers.log.LogManager import instance as logInstance +from utils.ThemeUtils import ( + readThemeInfo, + readThemeQss, + validateTheme, + wrapQssToAtheme +) + + +_active_style_name = "Fusion" + + +def setActiveStyle( + style_name: str +): + + global _active_style_name + _active_style_name = style_name + +def getActiveStyle( +) -> str: + + return _active_style_name + + +class ThemeManager: + """ + Theme manager class. + + Manages the themes storage directory, providing import, + list, remove, and apply operations for .altheme theme files. + + Args: + themes_dir (str): Path to the themes storage directory. + """ + + def __init__( + self, + themes_dir: str + ): + + self.__themes_dir = os.path.abspath(themes_dir) + self.__lock = threading.Lock() + self.__current_theme_name = "" + os.makedirs(self.__themes_dir, exist_ok=True) + + @staticmethod + def _colorSchemeFor( + theme: str + ) -> Qt.ColorScheme: + """ + Map a theme identifier to the corresponding Qt color scheme. + """ + + if theme == "dark": + return Qt.ColorScheme.Dark + elif theme == "light": + return Qt.ColorScheme.Light + else: + return Qt.ColorScheme.Unknown + + def _deleteThemeFile( + self, + name: str + ): + """ + Delete a theme file in the themes storage directory. + + The caller must hold self.__lock before invoking this method. + + **This method ONLY deletes the file**. + """ + + filepath = os.path.join(self.__themes_dir, name + ".altheme") + if os.path.isfile(filepath): + os.remove(filepath) + + def _resolveDestPath( + self, + theme_name: str, + author: str + ) -> str: + """ + Resolve the destination path for an imported theme. + + If the default {name}.altheme path does not exist, use it directly. + If it exists and has a different author, use {name}_{author}.altheme. + If it exists and has the same author, raise ValueError. + + Args: + theme_name (str): Sanitised theme name. + author (str): Theme author string. + + Returns: + str: The resolved destination file path. + + Raises: + ValueError: If a theme with the same name and author already exists. + """ + + default_path = os.path.join(self.__themes_dir, theme_name + ".altheme") + if not os.path.exists(default_path): + return default_path + try: + existing_info = validateTheme(default_path) + existing_author = existing_info.get("author", "") + except Exception: + self._deleteThemeFile(theme_name) # caller holds the lock + raise ValueError( + f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件" + ) + if existing_author == author: + raise ValueError( + f"主题名称 '{theme_name}' (作者 '{author}') 已存在" + ) + safe_author = os.path.basename(author) if author else "未知作者" + alt_path = os.path.join( + self.__themes_dir, f"{theme_name}_{safe_author}.altheme" + ) + if os.path.exists(alt_path): + raise ValueError( + f"主题名称 '{theme_name}' (作者 '{author}') 已存在" + ) + return alt_path + + def themesDir( + self + ) -> str: + """ + Get the themes directory path. + + Returns: + str: The absolute path to the themes storage directory. + """ + + return self.__themes_dir + + def importTheme( + self, + source_path: str + ) -> str: + """ + Import a theme file into the themes directory. + + Supports .altheme (zip archive) and bare .qss files. + Bare .qss files are automatically wrapped into .altheme format. + For .altheme files, validates that theme.qss exists in the archive + and sanitises the theme name to prevent path traversal. + + Args: + source_path (str): Path to the .altheme or .qss file. + + Returns: + str: The imported theme name. + + Raises: + FileNotFoundError: If source_path does not exist. + ValueError: If the file type is unsupported or the .altheme is invalid. + """ + + if not os.path.isfile(source_path): + raise FileNotFoundError(source_path) + base_name, ext = os.path.splitext(os.path.basename(source_path)) + ext = ext.lower() + with self.__lock: + if ext == ".qss": + dest_path = self._resolveDestPath(base_name, "未知作者") + wrapQssToAtheme(source_path, dest_path, "both") + return os.path.splitext(os.path.basename(dest_path))[0] + elif ext == ".altheme": + info = validateTheme(source_path) + name = info.get("name", base_name) + safe_name = os.path.basename(name) + new_author = info.get("author", "") + dest_path = self._resolveDestPath(safe_name, new_author) + shutil.copy2(source_path, dest_path) + return os.path.splitext(os.path.basename(dest_path))[0] + else: + raise ValueError(f"不支持的文件类型: {ext}") + + def listThemes( + self + ) -> list: + """ + List all available themes in the themes directory. + + Scans the themes directory for .altheme files and reads + their info.json metadata. + + Returns: + list[dict]: A list of theme info dictionaries. + """ + + themes = [] + seen_keys = set() + if not os.path.isdir(self.__themes_dir): + return themes + for filename in sorted(os.listdir(self.__themes_dir)): + if filename.endswith(".altheme"): + filepath = os.path.join(self.__themes_dir, filename) + try: + info = validateTheme(filepath) + name = info.get("name", "") + author = info.get("author", "") + key = (name, author) + if key in seen_keys: + logInstance().getLogger("ThemeManager").warning( + f"主题名称 '{name}' (作者 '{author}') 重复 (文件 '{filename}') 已跳过" + ) + continue + seen_keys.add(key) + info["file"] = os.path.splitext(filename)[0] + themes.append(info) + except Exception as e: + logInstance().getLogger("ThemeManager").warning( + f"无法读取主题文件 '{filename}',已跳过: {e}" + ) + else: + logInstance().getLogger("ThemeManager").warning( + f"未知文件类型 '{filename}',已跳过" + ) + return themes + + def removeTheme( + self, + name: str + ): + """ + Remove a theme by name. + + If the removed theme is currently active, clears the QSS + stylesheet from the application and reverts to the saved + colour scheme. + + Args: + name (str): The theme name to remove. + """ + + with self.__lock: + self._deleteThemeFile(name) + if self.__current_theme_name == name: + self.__current_theme_name = "" + saved_theme = configInstance().get( + CfgKey.GLOBAL.APPEARANCE.THEME, "system" + ) + self.clearTheme(saved_theme) + + def applyTheme( + self, + name: str + ): + """ + Apply a theme by name. + + Extracts the QSS from the .altheme file, applies it to + QApplication, and sets the Qt color scheme based on + the theme's need_theme metadata. + + Args: + name (str): The theme name to apply. + + Raises: + FileNotFoundError: If the theme .altheme file does not exist. + """ + + filepath = os.path.join(self.__themes_dir, name + ".altheme") + if not os.path.isfile(filepath): + raise FileNotFoundError(filepath) + with self.__lock: + info = readThemeInfo(filepath) + qss = readThemeQss(filepath) + app = QApplication.instance() + if app: + app.setStyleSheet(qss) + need_theme = info.get("need_theme", "both") + app.styleHints().setColorScheme( + ThemeManager._colorSchemeFor(need_theme) + ) + app.setStyle(QStyleFactory.create(_active_style_name)) + self.__current_theme_name = name + + def clearTheme( + self, + theme: str + ): + """ + Clear the current QSS stylesheet and apply the given color scheme. + + Args: + theme (str): The color scheme to apply after clearing + ("light", "dark", or "system"). + """ + + app = QApplication.instance() + if not app: + return + app.setStyleSheet("") + app.styleHints().setColorScheme( + ThemeManager._colorSchemeFor(theme) + ) + app.setStyle(QStyleFactory.create(_active_style_name)) + + +# ThemeManager singleton instance. +_theme_manager_instance = None + +# Singleton instance lock. +_instance_lock = threading.Lock() + + +def instance( + themes_dir: str = "" +) -> ThemeManager: + """ + Get the ThemeManager singleton instance. + + On first call, initialises the ThemeManager with the themes + directory derived from ConfigManager's config directory. + + Args: + themes_dir (str): Optional themes directory path. + + Returns: + ThemeManager: The singleton ThemeManager instance. + """ + + global _theme_manager_instance + with _instance_lock: + if _theme_manager_instance is None: + if not themes_dir: + cfg = configInstance() + themes_dir = os.path.join(cfg.configDir(), "themes") + _theme_manager_instance = ThemeManager(themes_dir) + return _theme_manager_instance diff --git a/src/managers/theme/__init__.py b/src/managers/theme/__init__.py new file mode 100644 index 0000000..5cb1dac --- /dev/null +++ b/src/managers/theme/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py new file mode 100644 index 0000000..94ed0d0 --- /dev/null +++ b/src/utils/ThemeUtils.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import json +import os +import zipfile + + +def packTheme( + qss_path: str, + info: dict, + output_path: str +): + """ + Pack a .qss file and info dict into a .altheme file. + + The .altheme file is a zip archive containing info.json and theme.qss. + + Args: + qss_path (str): Path to the .qss stylesheet file. + info (dict): Theme metadata dict with keys name, author, need_theme, brief. + output_path (str): Destination path for the .altheme file. + + Raises: + FileNotFoundError: If qss_path does not exist. + """ + + if not os.path.isfile(qss_path): + raise FileNotFoundError(qss_path) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4)) + zf.write(qss_path, "theme.qss") + +def unpackTheme( + altheme_path: str, + output_dir: str +): + """ + Extract a .altheme file to a directory. + + Performs Zip Slip validation before extraction. + + Args: + altheme_path (str): Path to the .altheme file. + output_dir (str): Directory to extract contents into. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If a zip entry contains an unsafe path. + """ + + if not os.path.isfile(altheme_path): + raise FileNotFoundError(altheme_path) + os.makedirs(output_dir, exist_ok=True) + with zipfile.ZipFile(altheme_path, "r") as zf: + for name in zf.namelist(): + if name.startswith("/") or ".." in name: + raise ValueError(f"不安全的 .altheme 入口: {name}") + zf.extractall(output_dir) + +def readThemeInfo( + altheme_path: str +) -> dict: + """ + Read and validate the info.json metadata from a .altheme file. + + Verifies that all required fields (name, author, need_theme, brief) + are present with valid values. + + Args: + altheme_path (str): Path to the .altheme file. + + Returns: + dict: The validated theme metadata dictionary. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If info.json is missing or any field is invalid. + """ + + if not os.path.isfile(altheme_path): + raise FileNotFoundError(altheme_path) + with zipfile.ZipFile(altheme_path, "r") as zf: + if "info.json" not in zf.namelist(): + raise ValueError("无效的 .altheme: 缺少 info.json") + with zf.open("info.json") as fh: + try: + info = json.loads(fh.read().decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}") + if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip(): + raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段") + # reject blank author so that info.json does not drift from the + # "未知作者" filename fallback used by wrapQssToAtheme + if ("author" not in info or not isinstance(info.get("author"), str) + or not info["author"].strip()): + raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段") + need_theme = info.get("need_theme", "both") + if need_theme not in ("light", "dark", "both"): + raise ValueError( + f"无效的 .altheme: need_theme 值 '{need_theme}' 无效, " + f"应为 'light'、'dark' 或 'both'" + ) + if "brief" not in info or not isinstance(info.get("brief"), str): + raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段") + return info + +def readThemeQss( + altheme_path: str +) -> str: + """ + Read the theme.qss content from a .altheme archive. + + Args: + altheme_path (str): Path to the .altheme file. + + Returns: + str: The non-empty QSS stylesheet content. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If theme.qss is missing or empty. + """ + + if not os.path.isfile(altheme_path): + raise FileNotFoundError(altheme_path) + with zipfile.ZipFile(altheme_path, "r") as zf: + if "theme.qss" not in zf.namelist(): + raise ValueError("无效的 .altheme: 缺少 theme.qss") + qss = zf.read("theme.qss").decode("utf-8") + if not qss.strip(): + raise ValueError("无效的 .altheme: theme.qss 为空") + return qss + +def validateTheme( + altheme_path: str +) -> dict: + """ + Fully validate a .altheme file and return its metadata. + + Delegates info validation to readThemeInfo and QSS validation + to readThemeQss, then additionally checks that theme.qss is + non-empty. + + Args: + altheme_path (str): Path to the .altheme file. + + Returns: + dict: The validated theme metadata dictionary. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If validation fails for any reason. + """ + + info = readThemeInfo(altheme_path) + readThemeQss(altheme_path) # validates existence and non-empty + return info + +def wrapQssToAtheme( + qss_path: str, + output_path: str, + current_theme: str +): + """ + Wrap a bare .qss file into a .altheme file with auto-generated metadata. + + The generated info.json uses the filename as the theme name + and sets default values for author and brief. + + Args: + qss_path (str): Path to the bare .qss stylesheet file. + output_path (str): Destination path for the .altheme file. + current_theme (str): The need_theme value to embed in metadata + ("light", "dark", or "both"). + + Raises: + FileNotFoundError: If qss_path does not exist. + """ + + filename = os.path.splitext(os.path.basename(qss_path))[0] + info = { + "name": filename, + "author": "未知作者", + "need_theme": current_theme, + "brief": "没有相关简介" + } + packTheme(qss_path, info, output_path)