From 05b93799d408b797b5db423f728c1c4f708cca6b Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 17:56:21 +0800 Subject: [PATCH 01/31] =?UTF-8?q?feat(gui):=20=E5=BC=95=E5=85=A5=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E8=AE=BE=E7=BD=AE=E7=AA=97=E5=8F=A3=20ALSettingsWidge?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ALSettingsWidget,左侧导航+右侧内容的设置面板 - 合并外观主题、界面风格、自定义QSS为单页布局 - 深浅色主题通过 Qt.ColorScheme 官方 API 实现 - 界面风格变更检测基于当前运行的 QStyle 比对 - 使用 qtawesome 提供矢量导航图标 - 风格变更时弹出重启确认对话框 - ALAutoScriptEditDialog 中重置/复制按钮改用 qtawesome 图标 - 外观初始化迁移至 boot.AppInitializer - 菜单栏新增工具→全局设置入口 - GLOBAL 配置扩展 appearance 段(theme/style/custom_qss) Co-Authored-By: Claude Opus 4.8 --- requirements.txt | Bin 1490 -> 1588 bytes src/Main.py | 1 - src/boot/AppInitializer.py | 20 +- src/gui/ALAutoScriptEditDialog.py | 19 +- src/gui/ALMainWindow.py | 32 +- src/gui/ALSettingsWidget.py | 340 ++++++++++++++++ src/gui/resources/ui/ALMainWindow.ui | 12 + src/gui/resources/ui/ALSettingsWidget.ui | 480 +++++++++++++++++++++++ src/interfaces/ConfigProvider.py | 6 + src/managers/config/ConfigManager.py | 5 + 10 files changed, 906 insertions(+), 9 deletions(-) create mode 100644 src/gui/ALSettingsWidget.py create mode 100644 src/gui/resources/ui/ALSettingsWidget.ui diff --git a/requirements.txt b/requirements.txt index b85c98d87b0d8fdbee7985539adb91ceeaf2801b..95204cc9a1eb0e474262b145072cb7b46c881a72 100644 GIT binary patch delta 106 zcmcb_y@f~Z|Gz|r9EK8xbcP~^M1}%}37s0xJZm31FxM%NT)VHlJr^W&!}UqZ7{n delta 16 YcmdnObBUYj|G$lD*O)h3vHW5L06@hDIRF3v 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..4c0440e 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -10,10 +10,13 @@ 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 gui.ALSettingsWidget import _applyTheme +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 def _initializeLogManager( @@ -64,13 +67,25 @@ 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") + app.setStyle(saved_style) + _applyTheme(saved_theme) + def initializeApp( ) -> bool: """ Initialize the application components Order: - LogManager -> ConfigManager -> WebDriverManager + LogManager -> ConfigManager -> WebDriverManager -> Appearance """ if not _initializeLogManager(): @@ -79,4 +94,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..a501de0 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, ) @@ -215,8 +216,8 @@ class ALAutoScriptEditDialog(QDialog): self.ZoomOutBtn = QPushButton("-") 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("fa5s.undo", color=self._iconColor())) + self.ZoomResetBtn.setIconSize(QSize(14, 14)) self.ZoomResetBtn.setFixedSize(25, 25) self.ZoomResetBtn.setToolTip("重置缩放") self.ZoomLabel = QLabel(f"{self._fontSize}px") @@ -240,8 +241,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("fa5s.copy", color=self._iconColor())) + self.CopyBtn.setIconSize(QSize(14, 14)) self.CopyBtn.setFixedSize(25, 25) self.CopyBtn.setToolTip("复制脚本") ToolbarLayout.addWidget(self.CopyBtn) @@ -537,6 +538,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/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/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py new file mode 100644 index 0000000..1fe44fb --- /dev/null +++ b/src/gui/ALSettingsWidget.py @@ -0,0 +1,340 @@ +# -*- 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, + QShowEvent +) +from PySide6.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QStyle, + QStyleFactory, + QWidget +) + +import managers.config.ConfigManager as ConfigManager + +from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget +from interfaces.ConfigProvider import ( + CfgKey, + ConfigProvider +) + + +def _clearQss( +): + + app : QApplication | None = QApplication.instance() + if app: + app.setStyleSheet("") + +def _loadQss( + file_path: str +) -> str: + + if not file_path or not os.path.isfile(file_path): + return "" + try: + with open(file_path, "r", encoding="utf-8") as fh: + return fh.read() + except Exception: + return "" + +def _applyQss( + file_path: str +): + + app : QApplication | None = QApplication.instance() + if not app: + return + qss = _loadQss(file_path) + if qss: + app.setStyleSheet(qss) + else: + _clearQss() + +def _applyTheme( + theme: str +): + + app : QApplication | None = QApplication.instance() + if not app: + return + if theme == "dark": + app.styleHints().setColorScheme(Qt.ColorScheme.Dark) + elif theme == "light": + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + else: + app.styleHints().setColorScheme(Qt.ColorScheme.Unknown) + app.setStyle(QStyleFactory.create(app.style().objectName())) + +def _restartApp( +): + + QApplication.instance().quit() + QProcess.startDetached(sys.executable, sys.argv) + + +class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): + + settingsWidgetIsClosed = Signal() + + def __init__( + self, + parent=None + ): + super().__init__(parent) + self.__cfg_mgr: ConfigProvider = ConfigManager.instance() + self.__original_style: QStyle | None = None + + self.setupUi(self) + self.modifyUi() + self.connectSignals() + self.loadSettings() + + def modifyUi( + self + ): + + self.setWindowFlags(Qt.WindowType.Window) + self.NavigationList.setCurrentRow(0) + self.populateStyles() + self.setNavigationIcons() + + 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("fa5s.palette", color=color)) + + def populateStyles( + self + ): + + self.StyleComboBox.clear() + self.StyleComboBox.addItems(QStyleFactory.keys()) + + def connectSignals( + self + ): + + self.BrowseQssButton.clicked.connect(self.onBrowseQssButtonClicked) + self.ApplyQssButton.clicked.connect(self.onApplyQssButtonClicked) + self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked) + self.CancelButton.clicked.connect(self.onCancelButtonClicked) + self.ApplyButton.clicked.connect(self.onApplyButtonClicked) + self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) + + def showEvent( + self, + event: QShowEvent + ): + + 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 + ): + + self.settingsWidgetIsClosed.emit() + super().closeEvent(event) + + def loadSettings( + self + ): + + theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") + style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") + custom_qss = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") + self.__original_style = QApplication.instance().style() + 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: + self.StyleComboBox.setCurrentIndex(index) + else: + self.StyleComboBox.setCurrentIndex(0) + self.QssPathEdit.setText(custom_qss) + self.updateQssStatus(custom_qss) + + def updateQssStatus( + self, + qss_path: str + ): + + if qss_path and os.path.isfile(qss_path): + self.QssStatusLabel.setText(f"已加载自定义样式文件:{qss_path}") + else: + self.QssStatusLabel.setText("当前使用程序默认外观。") + + def collectSettings( + self + ): + + if self.LightThemeRadio.isChecked(): + theme = "light" + elif self.DarkThemeRadio.isChecked(): + theme = "dark" + else: + theme = "system" + style = QStyleFactory.create(self.StyleComboBox.currentText()) + custom_qss = self.QssPathEdit.text().strip() + return theme, style, custom_qss + + def saveAndApply( + self + ): + + theme, style, custom_qss = self.collectSettings() + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style.name()) + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, custom_qss) + if custom_qss and os.path.isfile(custom_qss): + _applyQss(custom_qss) + else: + _clearQss() + _applyTheme(theme) + self.setNavigationIcons() + self.updateQssStatus(custom_qss) + self.__original_style = QApplication.instance().style() + + 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 onBrowseQssButtonClicked( + self + ): + + file_path, _ = QFileDialog.getOpenFileName( + self, + "选择 QSS 样式文件 - AutoLibrary", + self.QssPathEdit.text(), + "QSS 样式表文件 (*.qss);;所有文件 (*)" + ) + if file_path: + self.QssPathEdit.setText(file_path) + + @Slot() + def onApplyQssButtonClicked( + self + ): + + qss_path = self.QssPathEdit.text().strip() + if not qss_path: + QMessageBox.warning( + self, + "提示 - AutoLibrary", + "请先选择或输入 QSS 样式表文件路径。" + ) + return + if not os.path.isfile(qss_path): + QMessageBox.warning( + self, + "警告 - AutoLibrary", + f"未找到指定的样式文件:\n{qss_path}" + ) + return + _applyQss(qss_path) + self.updateQssStatus(qss_path) + + @Slot() + def onResetQssButtonClicked( + self + ): + + self.QssPathEdit.clear() + _clearQss() + if self.LightThemeRadio.isChecked(): + _applyTheme("light") + elif self.DarkThemeRadio.isChecked(): + _applyTheme("dark") + else: + _applyTheme("system") + self.setNavigationIcons() + self.updateQssStatus("") + + @Slot() + def onCancelButtonClicked( + self + ): + + self.close() + + @Slot() + def onApplyButtonClicked( + self + ): + + _, style, _ = self.collectSettings() + style_changed = self.__original_style.name() != style.name() + self.saveAndApply() + if style_changed: + self.maybeRestart() + + @Slot() + def onConfirmButtonClicked( + self + ): + + _, style, _ = self.collectSettings() + style_changed = self.__original_style.name() != style.name() + self.saveAndApply() + if style_changed: + self.maybeRestart() + self.close() 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..7db0631 --- /dev/null +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -0,0 +1,480 @@ + + + ALSettingsWidget + + + + 0 + 0 + 400 + 420 + + + + + 400 + 420 + + + + + 500 + 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 + + + + 外观 + + + + + + + + + + + 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 + + + + + + 0 + 25 + + + + 选择或输入 QSS 样式表文件路径... + + + + + + + + 25 + 25 + + + + + 25 + 25 + + + + ... + + + + + + + + + 5 + + + + + + 80 + 25 + + + + 应用样式 + + + + + + + + 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/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py index 9ee5bb4..51e0d2a 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_QSS = ConfigPath(ConfigType.GLOBAL, "appearance.custom_qss") + 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..58f0787 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_qss": "" } } case ConfigType.BULLETIN: From 9c1772b186adb4dcc7ad475224080fd0997dc9b3 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 19:27:10 +0800 Subject: [PATCH 02/31] =?UTF-8?q?feat(theme):=20=E6=96=B0=E5=A2=9E=20BlueF?= =?UTF-8?q?orest=20=E5=AE=98=E6=96=B9=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 BlueForest.qss,基于 Fusion 控件规格的纯配色深色主题 - 深蓝底色 + 亮青绿强调色,控件尺寸与 Fusion 风格保持一致 - 全局统一 selection-background-color 为 #2dd4bf - 背景色分层:页面 > 头部栏 > 交互控件 > 弹出层 > 输入区 - Border 属性统一拆分为 style/color/width 三段式 - AppInitializer / ALSettingsWidget 配合主题加载 Co-Authored-By: Claude Opus 4.8 --- src/boot/AppInitializer.py | 9 +- src/gui/ALSettingsWidget.py | 49 ++- src/gui/resources/themes/BlueForest.qss | 427 +++++++++++++++++++ src/gui/resources/ui/ALTimerTaskAddDialog.ui | 4 +- 4 files changed, 468 insertions(+), 21 deletions(-) create mode 100644 src/gui/resources/themes/BlueForest.qss diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index 4c0440e..11f8a50 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -12,7 +12,11 @@ import os from PySide6.QtCore import QStandardPaths, QDir from PySide6.QtWidgets import QApplication -from gui.ALSettingsWidget import _applyTheme +from gui.ALSettingsWidget import ( + _setActiveStyleName, + _applyTheme, + _applyQss, +) from interfaces.ConfigProvider import CfgKey from managers.config.ConfigManager import instance as configInstance from managers.driver.WebDriverManager import instance as webdriverInstance @@ -76,7 +80,10 @@ def _initializeAppearance( cfg = configInstance() saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") + saved_qss = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") app.setStyle(saved_style) + _setActiveStyleName(saved_style) + _applyQss(saved_qss) _applyTheme(saved_theme) def initializeApp( diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 1fe44fb..6ce37d2 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -26,7 +26,6 @@ from PySide6.QtWidgets import ( QApplication, QFileDialog, QMessageBox, - QStyle, QStyleFactory, QWidget ) @@ -40,6 +39,16 @@ from interfaces.ConfigProvider import ( ) +_active_style_name = "" + + +def _setActiveStyleName( + name: str +): + + global _active_style_name + _active_style_name = name + def _clearQss( ): @@ -76,6 +85,7 @@ def _applyTheme( theme: str ): + global _active_style_name app : QApplication | None = QApplication.instance() if not app: return @@ -85,7 +95,7 @@ def _applyTheme( app.styleHints().setColorScheme(Qt.ColorScheme.Light) else: app.styleHints().setColorScheme(Qt.ColorScheme.Unknown) - app.setStyle(QStyleFactory.create(app.style().objectName())) + app.setStyle(QStyleFactory.create(_active_style_name)) def _restartApp( ): @@ -104,7 +114,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): super().__init__(parent) self.__cfg_mgr: ConfigProvider = ConfigManager.instance() - self.__original_style: QStyle | None = None + self.__original_style: str = "" self.setupUi(self) self.modifyUi() @@ -137,6 +147,12 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.StyleComboBox.clear() self.StyleComboBox.addItems(QStyleFactory.keys()) + def currentStyleKey( + self + ) -> str: + + return _active_style_name + def connectSignals( self ): @@ -184,7 +200,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") custom_qss = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") - self.__original_style = QApplication.instance().style() + self.__original_style = self.currentStyleKey() if theme == "light": self.LightThemeRadio.setChecked(True) elif theme == "dark": @@ -192,10 +208,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: self.SystemThemeRadio.setChecked(True) index = self.StyleComboBox.findText(style) - if index >= 0: - self.StyleComboBox.setCurrentIndex(index) - else: - self.StyleComboBox.setCurrentIndex(0) + if index < 0: + index = 0 + self.StyleComboBox.setCurrentIndex(index) self.QssPathEdit.setText(custom_qss) self.updateQssStatus(custom_qss) @@ -205,7 +220,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): if qss_path and os.path.isfile(qss_path): - self.QssStatusLabel.setText(f"已加载自定义样式文件:{qss_path}") + filename = os.path.basename(qss_path) + self.QssStatusLabel.setText(f"已加载自定义样式文件:{filename}") else: self.QssStatusLabel.setText("当前使用程序默认外观。") @@ -219,7 +235,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): theme = "dark" else: theme = "system" - style = QStyleFactory.create(self.StyleComboBox.currentText()) + style = self.StyleComboBox.currentText() custom_qss = self.QssPathEdit.text().strip() return theme, style, custom_qss @@ -229,16 +245,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): theme, style, custom_qss = self.collectSettings() self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) - self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style.name()) + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, custom_qss) - if custom_qss and os.path.isfile(custom_qss): - _applyQss(custom_qss) - else: - _clearQss() + _applyQss(custom_qss) _applyTheme(theme) self.setNavigationIcons() self.updateQssStatus(custom_qss) - self.__original_style = QApplication.instance().style() + self.__original_style = self.currentStyleKey() def maybeRestart( self @@ -322,7 +335,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): _, style, _ = self.collectSettings() - style_changed = self.__original_style.name() != style.name() + style_changed = self.__original_style != style self.saveAndApply() if style_changed: self.maybeRestart() @@ -333,7 +346,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): _, style, _ = self.collectSettings() - style_changed = self.__original_style.name() != style.name() + style_changed = self.__original_style != style self.saveAndApply() if style_changed: self.maybeRestart() diff --git a/src/gui/resources/themes/BlueForest.qss b/src/gui/resources/themes/BlueForest.qss new file mode 100644 index 0000000..b984848 --- /dev/null +++ b/src/gui/resources/themes/BlueForest.qss @@ -0,0 +1,427 @@ +/* + * 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 Style Theme : BlueForest + */ + +/* ---- Global ---- */ +QWidget { + background-color: #0f1a2e; + color: #d0daf0; + selection-background-color: #2dd4bf; + selection-color: #0f1119; +} +QMainWindow::separator { + background-color: #1c2840; + width: 1px; + height: 1px; +} + +/* ---- Menu Bar ---- */ +QMenuBar { + background-color: #0f1628; + border-bottom: 1px solid #1c2840; + padding: 2px 6px; + color: #d0daf0; +} +QMenuBar::item { + padding: 4px 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: 2px; + 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; +} + +/* ---- 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; +} + +/* ---- Check Box / Radio Button ---- */ +QCheckBox, +QRadioButton { + spacing: 8px; + color: #d0daf0; +} +QCheckBox::indicator, +QRadioButton::indicator { + border-style: solid; + border-color: #334478; + border-width: 2px; + border-radius: 3px; + background-color: #0a1020; +} +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + border-color: #2dd4bf; +} +QCheckBox::indicator:checked { + background-color: #2dd4bf; + border-color: #2dd4bf; +} +QRadioButton::indicator { + border-radius: 10px; +} +QRadioButton::indicator:checked { + background-color: #2dd4bf; + border-color: #2dd4bf; +} +QCheckBox::indicator:disabled, +QRadioButton::indicator:disabled { + border-color: #253250; + background-color: #162038; +} + +/* ---- Group Box ---- */ +QGroupBox { + border-style: solid; + border-color: #253250; + border-width: 1px; + border-radius: 6px; + margin-top: 12px; + padding-top: 14px; + color: #d0daf0; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: #8b9ad0; +} + +/* ---- 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; +} +QTabBar::tab:hover:!selected { + background-color: #1c2840; + color: #d0daf0; +} + +/* ---- 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 10px; + border: none; +} +QListWidget::item:selected, +QTreeWidget::item:selected, +QTableWidget::item:selected { + background-color: #2dd4bf; + color: #0f1119; +} +QListWidget::item:hover:!selected, +QTreeWidget::item:hover:!selected { + background-color: #1c2840; +} +QHeaderView::section { + background-color: #0f1628; + border: none; + border-right: 1px solid #253250; + border-bottom: 1px solid #253250; + padding: 6px 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; +} + +/* ---- 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/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 From c0b6e0899c746e5b0d32e141b568e7322fc20fe8 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 19:54:25 +0800 Subject: [PATCH 03/31] =?UTF-8?q?fix(theme):=20=E4=BC=98=E5=8C=96=20BlueFo?= =?UTF-8?q?rest=20=E6=8C=89=E9=92=AE=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QPushButton 添加 min-width: 80px, min-height: 25px 统一按钮默认大小 - 移除无效的 QDialogButtonBox 选择器,对话框按钮直接继承 QPushButton - QPushButton padding 调整为 4px 12px,兼顾各场景按钮尺寸 Co-Authored-By: Claude Opus 4.8 --- src/gui/ALAutoScriptEditDialog.py | 6 ++++-- src/gui/resources/themes/BlueForest.qss | 8 +------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/gui/ALAutoScriptEditDialog.py b/src/gui/ALAutoScriptEditDialog.py index a501de0..0630c5e 100644 --- a/src/gui/ALAutoScriptEditDialog.py +++ b/src/gui/ALAutoScriptEditDialog.py @@ -223,11 +223,11 @@ class ALAutoScriptEditDialog(QDialog): 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() @@ -265,7 +265,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( diff --git a/src/gui/resources/themes/BlueForest.qss b/src/gui/resources/themes/BlueForest.qss index b984848..f6e10f1 100644 --- a/src/gui/resources/themes/BlueForest.qss +++ b/src/gui/resources/themes/BlueForest.qss @@ -11,12 +11,6 @@ */ /* ---- Global ---- */ -QWidget { - background-color: #0f1a2e; - color: #d0daf0; - selection-background-color: #2dd4bf; - selection-color: #0f1119; -} QMainWindow::separator { background-color: #1c2840; width: 1px; @@ -66,7 +60,7 @@ QPushButton { border-width: 1px; border-radius: 5px; color: #d0daf0; - padding: 2px; + padding: 4px 12px; background-color: #1c2840; } QPushButton:hover { From 35253dadbb634df1099be7b0487e81c3e787cfa1 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:01:18 +0800 Subject: [PATCH 04/31] =?UTF-8?q?feat(theme):=20=E5=BC=95=E5=85=A5=20.alth?= =?UTF-8?q?eme=20=E4=B8=BB=E9=A2=98=E6=96=87=E4=BB=B6=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E4=B8=BB=E9=A2=98=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .altheme 文件格式(zip 压缩包包含 info.json 与 theme.qss) - 新增 utils/ThemeUtils.py:主题文件打包/解包/读取工具函数 - 新增 managers/theme/ThemeManager:主题目录管理器,支持导入/列举/删除/应用 - 新增 LightLake 浅色主题 QSS 文件 - 新增 CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME 配置键 - 配置模板新增 custom_theme 字段 - ALSettingsWidget 接入 ThemeManager,替换裸 QSS 路径模式 - AppInitializer 启动时恢复自定义主题状态 - Zip Slip 防护与线程安全保护 --- src/boot/AppInitializer.py | 10 +- src/gui/ALSettingsWidget.py | 140 +++++--- src/gui/resources/themes/LightLake.qss | 421 +++++++++++++++++++++++++ src/interfaces/ConfigProvider.py | 1 + src/managers/config/ConfigManager.py | 3 +- src/managers/theme/ThemeManager.py | 252 +++++++++++++++ src/managers/theme/__init__.py | 9 + src/utils/ThemeUtils.py | 123 ++++++++ 8 files changed, 913 insertions(+), 46 deletions(-) create mode 100644 src/gui/resources/themes/LightLake.qss create mode 100644 src/managers/theme/ThemeManager.py create mode 100644 src/managers/theme/__init__.py create mode 100644 src/utils/ThemeUtils.py diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index 11f8a50..a4bd884 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -81,9 +81,17 @@ def _initializeAppearance( saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") saved_qss = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") + saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") app.setStyle(saved_style) _setActiveStyleName(saved_style) - _applyQss(saved_qss) + if saved_custom_theme: + try: + from managers.theme.ThemeManager import instance as themeInstance + themeInstance().applyTheme(saved_custom_theme) + except Exception: + _applyQss(saved_qss) + else: + _applyQss(saved_qss) _applyTheme(saved_theme) def initializeApp( diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 6ce37d2..44b58a6 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -24,6 +24,7 @@ from PySide6.QtGui import ( ) from PySide6.QtWidgets import ( QApplication, + QComboBox, QFileDialog, QMessageBox, QStyleFactory, @@ -31,6 +32,7 @@ from PySide6.QtWidgets import ( ) import managers.config.ConfigManager as ConfigManager +from managers.theme.ThemeManager import instance as themeInstance from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget from interfaces.ConfigProvider import ( @@ -56,6 +58,18 @@ def _clearQss( if app: app.setStyleSheet("") +def _applyThemeByName( + name: str +): + + if not name: + _clearQss() + return + try: + themeInstance().applyTheme(name) + except Exception: + _clearQss() + def _loadQss( file_path: str ) -> str: @@ -129,6 +143,15 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.NavigationList.setCurrentRow(0) self.populateStyles() self.setNavigationIcons() + self.QssPathEdit.hide() + self.ApplyQssButton.hide() + self.ResetQssButton.setText("重置主题") + self.CustomQssHintLabel.setText("选择一个主题,或导入新的主题文件:") + self.ThemeComboBox = QComboBox(self.CustomQssGroupBox) + self.ThemeComboBox.setObjectName("ThemeComboBox") + self.ThemeComboBox.setMinimumSize(160, 25) + self.QssPathLayout.insertWidget(0, self.ThemeComboBox) + self.ThemeStatusLabel = self.QssStatusLabel def setNavigationIcons( self @@ -157,8 +180,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self ): - self.BrowseQssButton.clicked.connect(self.onBrowseQssButtonClicked) - self.ApplyQssButton.clicked.connect(self.onApplyQssButtonClicked) + self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) + self.ThemeComboBox.currentTextChanged.connect(self.onThemeComboBoxChanged) self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.ApplyButton.clicked.connect(self.onApplyButtonClicked) @@ -199,7 +222,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") - custom_qss = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") + custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") self.__original_style = self.currentStyleKey() if theme == "light": self.LightThemeRadio.setChecked(True) @@ -211,19 +234,22 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if index < 0: index = 0 self.StyleComboBox.setCurrentIndex(index) - self.QssPathEdit.setText(custom_qss) - self.updateQssStatus(custom_qss) + self.populateThemeList() + if custom_theme: + idx = self.ThemeComboBox.findText(custom_theme) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + self.updateThemeStatus() - def updateQssStatus( - self, - qss_path: str + def updateThemeStatus( + self ): - if qss_path and os.path.isfile(qss_path): - filename = os.path.basename(qss_path) - self.QssStatusLabel.setText(f"已加载自定义样式文件:{filename}") + name = self.ThemeComboBox.currentText() + if name: + self.ThemeStatusLabel.setText(f"已加载主题:{name}") else: - self.QssStatusLabel.setText("当前使用程序默认外观。") + self.ThemeStatusLabel.setText("当前使用程序默认外观。") def collectSettings( self @@ -236,21 +262,21 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: theme = "system" style = self.StyleComboBox.currentText() - custom_qss = self.QssPathEdit.text().strip() - return theme, style, custom_qss + custom_theme = self.ThemeComboBox.currentText() + return theme, style, custom_theme def saveAndApply( self ): - theme, style, custom_qss = self.collectSettings() + theme, style, custom_theme = self.collectSettings() self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) - self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, custom_qss) - _applyQss(custom_qss) + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) + _applyThemeByName(custom_theme) _applyTheme(theme) self.setNavigationIcons() - self.updateQssStatus(custom_qss) + self.updateThemeStatus() self.__original_style = self.currentStyleKey() def maybeRestart( @@ -269,49 +295,75 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): return True return False + def populateThemeList( + self + ): + + self.ThemeComboBox.blockSignals(True) + self.ThemeComboBox.clear() + self.ThemeComboBox.addItem("") + self.__theme_cache = {} + themes = themeInstance().listThemes() + for t in themes: + name = t.get("name", f"未知主题 {len(self.__theme_cache)+1}") + if name: + self.__theme_cache[name] = t + self.ThemeComboBox.addItem(name) + self.ThemeComboBox.blockSignals(False) + @Slot() - def onBrowseQssButtonClicked( + def onImportThemeButtonClicked( self ): file_path, _ = QFileDialog.getOpenFileName( self, - "选择 QSS 样式文件 - AutoLibrary", - self.QssPathEdit.text(), - "QSS 样式表文件 (*.qss);;所有文件 (*)" + "导入主题 - AutoLibrary", + "", + "主题文件 (*.altheme *.qss);;所有文件 (*)" ) - if file_path: - self.QssPathEdit.setText(file_path) + if not file_path: + return + try: + name = themeInstance().importTheme(file_path) + self.populateThemeList() + idx = self.ThemeComboBox.findText(name) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + _applyThemeByName(name) + self.updateThemeStatus() + except Exception as e: + QMessageBox.warning( + self, + "导入失败 - AutoLibrary", + f"无法导入主题文件:{e}" + ) @Slot() - def onApplyQssButtonClicked( + def onThemeComboBoxChanged( self ): - qss_path = self.QssPathEdit.text().strip() - if not qss_path: - QMessageBox.warning( - self, - "提示 - AutoLibrary", - "请先选择或输入 QSS 样式表文件路径。" - ) - return - if not os.path.isfile(qss_path): - QMessageBox.warning( - self, - "警告 - AutoLibrary", - f"未找到指定的样式文件:\n{qss_path}" - ) - return - _applyQss(qss_path) - self.updateQssStatus(qss_path) + name = self.ThemeComboBox.currentText() + if name: + _applyThemeByName(name) + 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) + else: + _clearQss() + self.updateThemeStatus() @Slot() def onResetQssButtonClicked( self ): - self.QssPathEdit.clear() + self.ThemeComboBox.setCurrentIndex(0) _clearQss() if self.LightThemeRadio.isChecked(): _applyTheme("light") @@ -320,7 +372,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: _applyTheme("system") self.setNavigationIcons() - self.updateQssStatus("") + self.updateThemeStatus() @Slot() def onCancelButtonClicked( diff --git a/src/gui/resources/themes/LightLake.qss b/src/gui/resources/themes/LightLake.qss new file mode 100644 index 0000000..beb3f3a --- /dev/null +++ b/src/gui/resources/themes/LightLake.qss @@ -0,0 +1,421 @@ +/* + * 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 Style 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 6px; + color: #1a2740; +} +QMenuBar::item { + padding: 4px 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; +} + +/* ---- 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; +} + +/* ---- Check Box / Radio Button ---- */ +QCheckBox, +QRadioButton { + spacing: 8px; + color: #1a2740; +} +QCheckBox::indicator, +QRadioButton::indicator { + border-style: solid; + border-color: #90a4c4; + border-width: 2px; + border-radius: 3px; + background-color: #ffffff; +} +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + border-color: #0ea58a; +} +QCheckBox::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QRadioButton::indicator { + border-radius: 10px; +} +QRadioButton::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QCheckBox::indicator:disabled, +QRadioButton::indicator:disabled { + border-color: #c0cdda; + background-color: #e8ecf2; +} + +/* ---- Group Box ---- */ +QGroupBox { + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 6px; + margin-top: 12px; + padding-top: 14px; + color: #1a2740; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: #4a6080; +} + +/* ---- 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; +} +QTabBar::tab:hover:!selected { + background-color: #d5dde8; + color: #1a2740; +} + +/* ---- 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 10px; + border: none; +} +QListWidget::item:selected, +QTreeWidget::item:selected, +QTableWidget::item:selected { + background-color: #0ea58a; + color: #ffffff; +} +QListWidget::item:hover:!selected, +QTreeWidget::item:hover:!selected { + background-color: #e2e8f0; +} +QHeaderView::section { + background-color: #dce4ee; + border: none; + border-right: 1px solid #c0cdda; + border-bottom: 1px solid #c0cdda; + padding: 6px 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; +} + +/* ---- 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/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py index 51e0d2a..a4a50ed 100644 --- a/src/interfaces/ConfigProvider.py +++ b/src/interfaces/ConfigProvider.py @@ -71,6 +71,7 @@ class CfgKey: THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme") STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style") CUSTOM_QSS = ConfigPath(ConfigType.GLOBAL, "appearance.custom_qss") + CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme") class TIMERTASK: ROOT = ConfigPath(ConfigType.TIMERTASK, "") diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py index 58f0787..bc2b38e 100644 --- a/src/managers/config/ConfigManager.py +++ b/src/managers/config/ConfigManager.py @@ -58,7 +58,8 @@ class ConfigTemplate: "appearance": { "theme": "system", "style": "Fusion", - "custom_qss": "" + "custom_qss": "", + "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..dcf68da --- /dev/null +++ b/src/managers/theme/ThemeManager.py @@ -0,0 +1,252 @@ +# -*- 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 tempfile +import threading +import zipfile + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from managers.config.ConfigManager import instance as configInstance +from utils.ThemeUtils import ( + packTheme, + readThemeInfo, + unpackTheme +) + + +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) + + 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) + ext = os.path.splitext(source_path)[1].lower() + if ext == ".qss": + name = os.path.splitext(os.path.basename(source_path))[0] + info = { + "name": name, + "author": "未知", + "need_theme": "both", + "brief": "没有相关简介" + } + dest_path = os.path.join(self.__themes_dir, name + ".altheme") + packTheme(source_path, info, dest_path) + return name + elif ext == ".altheme": + with zipfile.ZipFile(source_path, "r") as zf: + if "theme.qss" not in zf.namelist(): + raise ValueError("无效的 .altheme: 缺少 theme.qss") + info = readThemeInfo(source_path) + name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) + safe_name = os.path.basename(name) + dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") + shutil.copy2(source_path, dest_path) + return safe_name + 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 = [] + 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 = readThemeInfo(filepath) + themes.append(info) + except Exception: + pass + 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. + + Args: + name (str): The theme name to remove. + """ + + filepath = os.path.join(self.__themes_dir, name + ".altheme") + with self.__lock: + if os.path.isfile(filepath): + os.remove(filepath) + if self.__current_theme_name == name: + self.__current_theme_name = "" + self._clearQss() + + 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) + info = readThemeInfo(filepath) + with tempfile.TemporaryDirectory() as tmpdir: + unpackTheme(filepath, tmpdir) + qss_path = os.path.join(tmpdir, "theme.qss") + if os.path.isfile(qss_path): + with open(qss_path, "r", encoding="utf-8") as fh: + qss = fh.read() + app = QApplication.instance() + if app: + app.setStyleSheet(qss) + app = QApplication.instance() + if app: + need_theme = info.get("need_theme", "both") + if need_theme == "dark": + app.styleHints().setColorScheme(Qt.ColorScheme.Dark) + elif need_theme == "light": + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + with self.__lock: + self.__current_theme_name = name + + def currentThemeName( + self + ) -> str: + """ + Get the name of the currently active theme. + + Returns: + str: Current theme name, or empty string if none is active. + """ + + return self.__current_theme_name + + def _clearQss( + self + ): + """ + Clear the current QSS stylesheet from the application. + """ + + app = QApplication.instance() + if app: + app.setStyleSheet("") + +# 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..5946ef6 --- /dev/null +++ b/src/utils/ThemeUtils.py @@ -0,0 +1,123 @@ +# -*- 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 only the info.json metadata from a .altheme file. + + Args: + altheme_path (str): Path to the .altheme file. + + Returns: + dict: The theme metadata dictionary. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If the .altheme does not contain info.json. + """ + + 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: + return json.loads(fh.read().decode("utf-8")) + + +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) From 38489191f561ca5e37db3145134b6581a7e8bf15 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:11:01 +0800 Subject: [PATCH 05/31] =?UTF-8?q?refactor(gui):=20=E5=B0=86=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E6=8E=A7=E4=BB=B6=E6=A0=B7=E5=BC=8F=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=87=B3=20.ui=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeComboBox 移入 ALEttingsWidget.ui,由 setupUi 创建 - QssPathEdit/ApplyQssButton 的隐藏改为 .ui 的 visible=false - BrowseQssButton/ResetQssButton/CustomQssHintLabel 文本直接在 .ui 中定义 - ALSettingsWidget.modifyUi 移除冗余的程序化 UI 修改 - 移除 ThemeStatusLabel 别名,直接使用 QssStatusLabel --- src/gui/ALSettingsWidget.py | 14 ++--------- src/gui/resources/ui/ALSettingsWidget.ui | 30 ++++++++++++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 44b58a6..bf4e9ef 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -24,7 +24,6 @@ from PySide6.QtGui import ( ) from PySide6.QtWidgets import ( QApplication, - QComboBox, QFileDialog, QMessageBox, QStyleFactory, @@ -143,15 +142,6 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.NavigationList.setCurrentRow(0) self.populateStyles() self.setNavigationIcons() - self.QssPathEdit.hide() - self.ApplyQssButton.hide() - self.ResetQssButton.setText("重置主题") - self.CustomQssHintLabel.setText("选择一个主题,或导入新的主题文件:") - self.ThemeComboBox = QComboBox(self.CustomQssGroupBox) - self.ThemeComboBox.setObjectName("ThemeComboBox") - self.ThemeComboBox.setMinimumSize(160, 25) - self.QssPathLayout.insertWidget(0, self.ThemeComboBox) - self.ThemeStatusLabel = self.QssStatusLabel def setNavigationIcons( self @@ -247,9 +237,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): name = self.ThemeComboBox.currentText() if name: - self.ThemeStatusLabel.setText(f"已加载主题:{name}") + self.QssStatusLabel.setText(f"已加载主题:{name}") else: - self.ThemeStatusLabel.setText("当前使用程序默认外观。") + self.QssStatusLabel.setText("当前使用程序默认外观。") def collectSettings( self diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 7db0631..78ddea1 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -259,7 +259,7 @@ - 自定义程序外观,文件加载后将立即生效。 + 选择一个主题,或导入新的主题文件: true @@ -271,8 +271,21 @@ 5 + + + + + 160 + 25 + + + + + + false + 0 @@ -288,18 +301,12 @@ - 25 - 25 - - - - - 25 + 60 25 - ... + 导入 @@ -312,6 +319,9 @@ + + false + 80 @@ -332,7 +342,7 @@ - 重置外观 + 重置主题 From c1004ed2bc284baef4fe0d4a7d4ccdcd7c229291 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:23:13 +0800 Subject: [PATCH 06/31] =?UTF-8?q?refactor(gui):=20=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=EF=BC=8C=E7=A7=BB=E9=99=A4=20ComboBox=20=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 ThemeComboBox.currentTextChanged 信号连接 - 主题仅通过"应用"/"确认"按钮显式应用 - 导入主题不再自动应用,仅选中并更新列表 - 取消时恢复原始主题与原 ComboBox 选中状态 - collectSettings 将"默认"统一转为空字符串 - saveAndApply 新增 _syncRadioFromNeedTheme 同步色调单选 --- src/gui/ALSettingsWidget.py | 71 ++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index bf4e9ef..6f1c608 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -127,6 +127,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): 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) @@ -171,7 +173,6 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) - self.ThemeComboBox.currentTextChanged.connect(self.onThemeComboBoxChanged) self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.ApplyButton.clicked.connect(self.onApplyButtonClicked) @@ -213,6 +214,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): 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 = self.currentStyleKey() if theme == "light": self.LightThemeRadio.setChecked(True) @@ -236,11 +239,24 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): name = self.ThemeComboBox.currentText() - if name: + if name and name != "默认": self.QssStatusLabel.setText(f"已加载主题:{name}") else: self.QssStatusLabel.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 ): @@ -253,6 +269,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): theme = "system" style = self.StyleComboBox.currentText() custom_theme = self.ThemeComboBox.currentText() + if custom_theme == "默认": + custom_theme = "" return theme, style, custom_theme def saveAndApply( @@ -264,6 +282,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) _applyThemeByName(custom_theme) + self._syncRadioFromNeedTheme(custom_theme) + theme, _, _ = self.collectSettings() _applyTheme(theme) self.setNavigationIcons() self.updateThemeStatus() @@ -291,11 +311,11 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.ThemeComboBox.blockSignals(True) self.ThemeComboBox.clear() - self.ThemeComboBox.addItem("") + self.ThemeComboBox.addItem("默认") self.__theme_cache = {} themes = themeInstance().listThemes() for t in themes: - name = t.get("name", f"未知主题 {len(self.__theme_cache)+1}") + name = t.get("name", "") if name: self.__theme_cache[name] = t self.ThemeComboBox.addItem(name) @@ -320,7 +340,6 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): idx = self.ThemeComboBox.findText(name) if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) - _applyThemeByName(name) self.updateThemeStatus() except Exception as e: QMessageBox.warning( @@ -329,46 +348,32 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): f"无法导入主题文件:{e}" ) - @Slot() - def onThemeComboBoxChanged( - self - ): - - name = self.ThemeComboBox.currentText() - if name: - _applyThemeByName(name) - 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) - else: - _clearQss() - self.updateThemeStatus() - @Slot() def onResetQssButtonClicked( self ): self.ThemeComboBox.setCurrentIndex(0) - _clearQss() - if self.LightThemeRadio.isChecked(): - _applyTheme("light") - elif self.DarkThemeRadio.isChecked(): - _applyTheme("dark") - else: - _applyTheme("system") - self.setNavigationIcons() - self.updateThemeStatus() @Slot() def onCancelButtonClicked( self ): + self.ThemeComboBox.blockSignals(True) + if self.__original_custom_theme: + idx = self.ThemeComboBox.findText(self.__original_custom_theme) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + else: + self.ThemeComboBox.setCurrentIndex(0) + self.ThemeComboBox.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.close() @Slot() From a2bc1881bc18305cd28db79ca47d8650bc25bed0 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:33:59 +0800 Subject: [PATCH 07/31] =?UTF-8?q?feat(gui):=20=E6=96=B0=E5=A2=9E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E4=BF=A1=E6=81=AF=E6=A0=87=E7=AD=BE=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20custom=5Fqss=20=E5=85=BC=E5=AE=B9=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=87=8D=E7=BD=AE=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .ui 新增 ThemeInfoLabel 用于展示主题作者和简介 - ALSettingsWidget 新增 _updateThemeInfo 方法,ComboBox 切换时更新信息 - 移除 _loadQss/_applyQss 模块函数及所有 CUSTOM_QSS 引用 - AppInitializer 移除 _applyQss 导入和回退逻辑 - ConfigProvider/ConfigManager 移除 custom_qss 键 - 纯 QSS 导入通过 ThemeManager 打包为 .altheme 统一管理 --- src/boot/AppInitializer.py | 6 +-- src/gui/ALSettingsWidget.py | 56 ++++++++++++------------ src/gui/resources/ui/ALSettingsWidget.ui | 34 ++++++++++---- src/interfaces/ConfigProvider.py | 1 - src/managers/config/ConfigManager.py | 1 - 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index a4bd884..42e6be1 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -15,7 +15,6 @@ from PySide6.QtWidgets import QApplication from gui.ALSettingsWidget import ( _setActiveStyleName, _applyTheme, - _applyQss, ) from interfaces.ConfigProvider import CfgKey from managers.config.ConfigManager import instance as configInstance @@ -80,7 +79,6 @@ def _initializeAppearance( cfg = configInstance() saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") - saved_qss = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") app.setStyle(saved_style) _setActiveStyleName(saved_style) @@ -89,9 +87,7 @@ def _initializeAppearance( from managers.theme.ThemeManager import instance as themeInstance themeInstance().applyTheme(saved_custom_theme) except Exception: - _applyQss(saved_qss) - else: - _applyQss(saved_qss) + pass _applyTheme(saved_theme) def initializeApp( diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 6f1c608..5ca3e91 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -69,31 +69,6 @@ def _applyThemeByName( except Exception: _clearQss() -def _loadQss( - file_path: str -) -> str: - - if not file_path or not os.path.isfile(file_path): - return "" - try: - with open(file_path, "r", encoding="utf-8") as fh: - return fh.read() - except Exception: - return "" - -def _applyQss( - file_path: str -): - - app : QApplication | None = QApplication.instance() - if not app: - return - qss = _loadQss(file_path) - if qss: - app.setStyleSheet(qss) - else: - _clearQss() - def _applyTheme( theme: str ): @@ -173,6 +148,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) + self.ThemeComboBox.currentTextChanged.connect(self.onThemeComboBoxChanged) self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.ApplyButton.clicked.connect(self.onApplyButtonClicked) @@ -233,6 +209,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) self.updateThemeStatus() + self._updateThemeInfo() def updateThemeStatus( self @@ -240,9 +217,25 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): name = self.ThemeComboBox.currentText() if name and name != "默认": - self.QssStatusLabel.setText(f"已加载主题:{name}") + self.QssStatusLabel.setText(f"当前使用 {name} 主题。") else: - self.QssStatusLabel.setText("当前使用程序默认外观。") + self.QssStatusLabel.setText("当前使用 默认 主题。") + + def _updateThemeInfo( + self + ): + + name = self.ThemeComboBox.currentText() + if not name or name == "默认": + self.ThemeInfoLabel.setText("") + return + t = self.__theme_cache.get(name) + if t: + author = t.get("author", "未知") + brief = t.get("brief", "没有相关简介") + self.ThemeInfoLabel.setText(f"作者:{author}\n简介:{brief}") + else: + self.ThemeInfoLabel.setText("") def _syncRadioFromNeedTheme( self, @@ -287,6 +280,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): _applyTheme(theme) self.setNavigationIcons() self.updateThemeStatus() + self._updateThemeInfo() self.__original_style = self.currentStyleKey() def maybeRestart( @@ -341,6 +335,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) self.updateThemeStatus() + self._updateThemeInfo() except Exception as e: QMessageBox.warning( self, @@ -348,6 +343,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): f"无法导入主题文件:{e}" ) + @Slot() + def onThemeComboBoxChanged( + self + ): + + self._updateThemeInfo() + @Slot() def onResetQssButtonClicked( self diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 78ddea1..21c5a8c 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -283,15 +283,15 @@ - - false - 0 25 + + false + 选择或输入 QSS 样式表文件路径... @@ -301,17 +301,33 @@ - 60 + 25 + 25 + + + + + 25 25 - 导入 + ... + + + + + + + true + + + @@ -319,15 +335,15 @@ - - false - 80 25 + + false + 应用样式 @@ -364,7 +380,7 @@ - 当前使用程序默认外观。 + 当前使用程序 默认 外观。 true diff --git a/src/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py index a4a50ed..d4f180a 100644 --- a/src/interfaces/ConfigProvider.py +++ b/src/interfaces/ConfigProvider.py @@ -70,7 +70,6 @@ class CfgKey: ROOT = ConfigPath(ConfigType.GLOBAL, "appearance") THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme") STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style") - CUSTOM_QSS = ConfigPath(ConfigType.GLOBAL, "appearance.custom_qss") CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme") class TIMERTASK: diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py index bc2b38e..0f54f13 100644 --- a/src/managers/config/ConfigManager.py +++ b/src/managers/config/ConfigManager.py @@ -58,7 +58,6 @@ class ConfigTemplate: "appearance": { "theme": "system", "style": "Fusion", - "custom_qss": "", "custom_theme": "" } } From 732f104c5cfa98b813a23b0c68af4702157821af Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:37:15 +0800 Subject: [PATCH 08/31] =?UTF-8?q?refactor(gui):=20=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=20=5FapplyThemeByName=20=E2=86=92=20=5FapplyCustomThe?= =?UTF-8?q?me=EF=BC=8C=5FclearQss=20=E2=86=92=20=5FclearCustomTheme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _clearCustomTheme(theme) 清除 QSS 后切换到指定默认主题 - _applyCustomTheme(name, fallback_theme) 应用自定义主题,失败时回退到 fallback_theme - saveAndApply 调用处传入当前 radio 主题作为 fallback --- src/gui/ALSettingsWidget.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 5ca3e91..2ce9db2 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -50,24 +50,27 @@ def _setActiveStyleName( global _active_style_name _active_style_name = name -def _clearQss( +def _clearCustomTheme( + theme: str ): app : QApplication | None = QApplication.instance() if app: app.setStyleSheet("") + _applyTheme(theme) -def _applyThemeByName( - name: str +def _applyCustomTheme( + name: str, + fallback_theme: str = "system" ): if not name: - _clearQss() + _clearCustomTheme(fallback_theme) return try: themeInstance().applyTheme(name) except Exception: - _clearQss() + _clearCustomTheme(fallback_theme) def _applyTheme( theme: str @@ -274,7 +277,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) - _applyThemeByName(custom_theme) + _applyCustomTheme(custom_theme, theme) self._syncRadioFromNeedTheme(custom_theme) theme, _, _ = self.collectSettings() _applyTheme(theme) From 645f07b4d222eb62acc18743018df5f5b6858a43 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:42:18 +0800 Subject: [PATCH 09/31] =?UTF-8?q?refactor(gui):=20currentTextChanged=20?= =?UTF-8?q?=E2=86=92=20currentIndexChanged=EF=BC=8CResetQssButton=20?= =?UTF-8?q?=E2=86=92=20ResetThemeButton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeComboBox 改用 currentIndexChanged(int) 信号 - ResetQssButton 重命名为 ResetThemeButton(.ui/.py 同步) - 重置按钮行为改为恢复至原始主题并立即应用(saveAndApply) --- src/gui/ALSettingsWidget.py | 39 ++++++++++++++++-------- src/gui/resources/ui/ALSettingsWidget.ui | 2 +- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 2ce9db2..8b1c398 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -151,8 +151,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) - self.ThemeComboBox.currentTextChanged.connect(self.onThemeComboBoxChanged) - self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked) + self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged) + self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.ApplyButton.clicked.connect(self.onApplyButtonClicked) self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) @@ -212,7 +212,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) self.updateThemeStatus() - self._updateThemeInfo() + self.updateThemeInfo() def updateThemeStatus( self @@ -224,7 +224,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: self.QssStatusLabel.setText("当前使用 默认 主题。") - def _updateThemeInfo( + def updateThemeInfo( self ): @@ -240,7 +240,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: self.ThemeInfoLabel.setText("") - def _syncRadioFromNeedTheme( + def syncRadioFromNeedTheme( self, name: str ): @@ -278,12 +278,12 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) _applyCustomTheme(custom_theme, theme) - self._syncRadioFromNeedTheme(custom_theme) + self.syncRadioFromNeedTheme(custom_theme) theme, _, _ = self.collectSettings() _applyTheme(theme) self.setNavigationIcons() self.updateThemeStatus() - self._updateThemeInfo() + self.updateThemeInfo() self.__original_style = self.currentStyleKey() def maybeRestart( @@ -338,7 +338,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) self.updateThemeStatus() - self._updateThemeInfo() + self.updateThemeInfo() except Exception as e: QMessageBox.warning( self, @@ -348,17 +348,32 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): @Slot() def onThemeComboBoxChanged( - self + self, + index: int ): - self._updateThemeInfo() + self.updateThemeInfo() @Slot() - def onResetQssButtonClicked( + def onResetThemeButtonClicked( self ): - self.ThemeComboBox.setCurrentIndex(0) + self.ThemeComboBox.blockSignals(True) + if self.__original_custom_theme: + idx = self.ThemeComboBox.findText(self.__original_custom_theme) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + else: + self.ThemeComboBox.setCurrentIndex(0) + self.ThemeComboBox.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.saveAndApply() @Slot() def onCancelButtonClicked( diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 21c5a8c..c52c318 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -350,7 +350,7 @@ - + 80 From 1d9e41ab86963328dd12b89203c6e45855e9fe6f Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:56:19 +0800 Subject: [PATCH 10/31] =?UTF-8?q?fix(gui):=20=E9=87=8D=E7=BD=AE=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E8=A7=A6=E5=8F=91=E9=BB=98=E8=AE=A4=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重置按钮始终切到"默认"(index 0),清除自定义 QSS - 调用 _clearCustomTheme 实际清除样式并应用原始色调模式 --- src/gui/ALSettingsWidget.py | 43 ++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 8b1c398..3b64e90 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -94,6 +94,18 @@ def _restartApp( QApplication.instance().quit() QProcess.startDetached(sys.executable, sys.argv) +def _themeToReadable( + theme: str +) -> str: + + if theme == "dark": + return "深色" + elif theme == "light": + return "浅色" + elif theme == "both": + return "所有" + else: + return "未知" class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): @@ -235,8 +247,14 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): t = self.__theme_cache.get(name) if t: author = t.get("author", "未知") + need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") - self.ThemeInfoLabel.setText(f"作者:{author}\n简介:{brief}") + self.ThemeInfoLabel.setText( + f"{name}\n" + f"适用于 {_themeToReadable(need_theme)} 主题\n" + f"作者:{author}\n" + f"{brief}" + ) else: self.ThemeInfoLabel.setText("") @@ -360,12 +378,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): self.ThemeComboBox.blockSignals(True) - if self.__original_custom_theme: - idx = self.ThemeComboBox.findText(self.__original_custom_theme) - if idx >= 0: - self.ThemeComboBox.setCurrentIndex(idx) - else: - self.ThemeComboBox.setCurrentIndex(0) + self.ThemeComboBox.setCurrentIndex(0) self.ThemeComboBox.blockSignals(False) if self.__original_theme == "light": self.LightThemeRadio.setChecked(True) @@ -373,27 +386,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.DarkThemeRadio.setChecked(True) else: self.SystemThemeRadio.setChecked(True) - self.saveAndApply() + self.updateThemeInfo() @Slot() def onCancelButtonClicked( self ): - self.ThemeComboBox.blockSignals(True) - if self.__original_custom_theme: - idx = self.ThemeComboBox.findText(self.__original_custom_theme) - if idx >= 0: - self.ThemeComboBox.setCurrentIndex(idx) - else: - self.ThemeComboBox.setCurrentIndex(0) - self.ThemeComboBox.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.close() @Slot() From 1cfd7382be0237b78af86eb0d570a4d60e8376e4 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 21:58:40 +0800 Subject: [PATCH 11/31] =?UTF-8?q?fix(gui):=20=E4=BF=AE=E5=A4=8D=20ThemeInf?= =?UTF-8?q?oLabel=20=E5=AF=8C=E6=96=87=E6=9C=AC=E6=8D=A2=E8=A1=8C=E4=B8=8E?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置 textFormat=RichText,\n 替换为
实现正确换行 - .ui 添加 minimumHeight=60、alignment=AlignTop 防止多行文本被裁剪 --- src/gui/ALSettingsWidget.py | 7 ++++--- src/gui/resources/ui/ALSettingsWidget.ui | 12 ++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 3b64e90..cc64b96 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -134,6 +134,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.NavigationList.setCurrentRow(0) self.populateStyles() self.setNavigationIcons() + self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) def setNavigationIcons( self @@ -250,9 +251,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( - f"{name}\n" - f"适用于 {_themeToReadable(need_theme)} 主题\n" - f"作者:{author}\n" + f"{name}
" + f"适用于 {_themeToReadable(need_theme)} 主题
" + f"作者:{author}
" f"{brief}" ) else: diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index c52c318..28703a5 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -320,9 +320,21 @@
+ + + 0 + 60 + + + + Qt::TextFormat::RichText + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop +
true From e893752c253bb019c959bed89f16e8de0e39b12a Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:02:43 +0800 Subject: [PATCH 12/31] =?UTF-8?q?feat(gui):=20=E4=B8=BA=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=9D=A2=E6=9D=BF=E6=B7=BB=E5=8A=A0=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=8C=BA=E5=9F=9F=EF=BC=8C=E6=94=BE=E5=AE=BD=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppearancePageLayout 包裹在 QScrollArea 内,内容溢出时可滚动 - 移除垂直 Spacer(滚动区域内不需要) - 最小宽度 400→480,最大宽度 500→580,最大高度 420→不限 - .ui 与 Ui_ALSettingsWidget.py 同步更新 --- src/gui/ALSettingsWidget.py | 2 +- src/gui/resources/ui/ALSettingsWidget.ui | 73 +++++++++++++----------- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index cc64b96..e9378d0 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -252,7 +252,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( f"{name}
" - f"适用于 {_themeToReadable(need_theme)} 主题
" + f" - 适用于 {_themeToReadable(need_theme)} 主题
" f"作者:{author}
" f"{brief}" ) diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 28703a5..27fefa5 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -6,20 +6,20 @@ 0 0 - 400 + 520 420 - 400 + 480 420 - 500 - 420 + 580 + 16777215 @@ -104,24 +104,40 @@ - - - 5 + + + QFrame::Shape::NoFrame - - 3 + + true - - 3 - - - 3 - - - 3 - - - + + + + 0 + 0 + 450 + 380 + + + + + 5 + + + 3 + + + 3 + + + 3 + + + 3 + + + 主题模式 @@ -402,20 +418,9 @@ - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - + + + From d6e8eef8c8fca81605fa3a56cb987b43058e51ca Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:05:21 +0800 Subject: [PATCH 13/31] =?UTF-8?q?fix(gui):=20=E6=81=A2=E5=A4=8D=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E9=AB=98=E5=BA=A6=20420=EF=BC=8C=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E5=86=85=E6=B7=BB=E5=8A=A0=E5=BC=B9=E6=80=A7?= =?UTF-8?q?=20Spacer=20=E7=BB=B4=E6=8C=81=E6=8E=A7=E4=BB=B6=E9=97=B4?= =?UTF-8?q?=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/resources/ui/ALSettingsWidget.ui | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 27fefa5..050fda0 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -19,7 +19,7 @@ 580 - 16777215 + 420 @@ -415,6 +415,19 @@ + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + From ef903ee81755f050e7ddd02dfe0d58c9ee371618 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:07:48 +0800 Subject: [PATCH 14/31] =?UTF-8?q?fix(gui):=20ThemeInfoLabel=20=E4=BD=9C?= =?UTF-8?q?=E8=80=85=E4=B8=8E=E7=AE=80=E4=BB=8B=E7=BC=A9=E5=B0=8F=E5=AD=97?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 包裹作者和简介行 - 主题名称保持原有字号 --- src/gui/ALSettingsWidget.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index e9378d0..a32353d 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -251,10 +251,11 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( - f"{name}
" - f" - 适用于 {_themeToReadable(need_theme)} 主题
" + f"{name}" + f" - 适用于 {_themeToReadable(need_theme)} 主题
" + f"" f"作者:{author}
" - f"{brief}" + f"{brief}
" ) else: self.ThemeInfoLabel.setText("") From 9fdb6f7652ace59173b42dff651d670d0f2db319 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:19:04 +0800 Subject: [PATCH 15/31] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=20.ui=20XML?= =?UTF-8?q?=20=E6=A0=87=E7=AD=BE=E5=B5=8C=E5=A5=97=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppearancePageSpacer 从 CustomQssGroupBoxLayout 内移出,恢复为 AppearancePageLayout 的直接子项 - 补全缺失的 AppearancePageLayout 与 QScrollArea 闭合标签 - 修复 alignment 属性的 标签未正确闭合 --- src/gui/ALSettingsWidget.py | 8 ++--- src/gui/resources/ui/ALSettingsWidget.ui | 38 ++++++++++++------------ 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index a32353d..2ab75f0 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -251,11 +251,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( - f"{name}" - f" - 适用于 {_themeToReadable(need_theme)} 主题
" - f"" - f"作者:{author}
" - f"{brief}
" + f"{name} - 适用于 {_themeToReadable(need_theme)} 主题
" + f"作者:{author}

" + f"{brief}" ) else: self.ThemeInfoLabel.setText("") diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 050fda0..a661738 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -349,7 +349,7 @@ Qt::TextFormat::RichText
- Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop true @@ -415,28 +415,28 @@ - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + - - - + + + + From 10d731518aba01894f4ddf1ba93231643c6f1897 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:20:29 +0800 Subject: [PATCH 16/31] =?UTF-8?q?fix(gui):=20ThemeInfoLabel=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=BB=86=E8=BE=B9=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 palette(mid) 自适应当前主题色调 --- src/gui/ALSettingsWidget.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 2ab75f0..ccd3922 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -135,6 +135,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.populateStyles() self.setNavigationIcons() self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) + self.ThemeInfoLabel.setStyleSheet( + "border: 1px solid palette(mid); border-radius: 4px; padding: 4px;" + ) def setNavigationIcons( self From 2d77cbec79cc720fb38ed5cff782a563d15bc347 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:30:31 +0800 Subject: [PATCH 17/31] =?UTF-8?q?fix(gui):=20=E4=BF=AE=E5=A4=8D=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=B8=BB=E9=A2=98=E6=97=B6=E8=89=B2=E8=B0=83=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=B8=8E=E4=B8=BB=E9=A2=98=20need=5Ftheme=20=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 THEME 配置写入移至 syncRadioFromNeedTheme 之后 - 确保保存的色调模式值与主题实际兼容值一致,避免重启后错配 --- src/gui/ALSettingsWidget.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index ccd3922..95375a2 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -136,7 +136,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.setNavigationIcons() self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) self.ThemeInfoLabel.setStyleSheet( - "border: 1px solid palette(mid); border-radius: 4px; padding: 4px;" + "border: 1px solid #ccc; " \ + "border-radius: 2px;" \ + "padding: 5px;" ) def setNavigationIcons( @@ -295,12 +297,12 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): theme, style, custom_theme = self.collectSettings() - self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) _applyCustomTheme(custom_theme, theme) self.syncRadioFromNeedTheme(custom_theme) theme, _, _ = self.collectSettings() + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) _applyTheme(theme) self.setNavigationIcons() self.updateThemeStatus() From 62f8ec3d91b731986cc94540b9894011e1e2d74b Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 30 May 2026 22:51:05 +0800 Subject: [PATCH 18/31] =?UTF-8?q?refactor(theme):=20=E5=B0=86=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E4=B8=BB=E9=A2=98=E9=80=BB=E8=BE=91=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E8=87=B3=20ThemeManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ThemeManager 新增: - clearTheme(theme) — 清除 QSS 并应用指定色调 - applyThemeOrClear(name, fallback) — 应用或回退的封装 - _applyColorScheme(theme) — Qt ColorScheme 设置的统一入口 - themeToReadable(need_theme) — 静态工具方法 ALSettingsWidget 移除: - _clearCustomTheme → 改用 themeInstance().clearTheme() - _applyCustomTheme → 改用 themeInstance().applyThemeOrClear() - _themeToReadable → 改用 ThemeManager.themeToReadable() ALSettingsWidget 仅保留 _applyTheme(含 setStyle 逻辑,供 AppInitializer 使用) --- src/boot/AppInitializer.py | 5 +- src/gui/ALSettingsWidget.py | 49 ++--------- src/managers/theme/ThemeManager.py | 132 +++++++++++++++++++++++------ src/utils/ThemeUtils.py | 5 +- 4 files changed, 123 insertions(+), 68 deletions(-) diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index 42e6be1..c90cbbf 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -20,6 +20,7 @@ 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 instance as themeInstance def _initializeLogManager( @@ -82,12 +83,12 @@ def _initializeAppearance( saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") app.setStyle(saved_style) _setActiveStyleName(saved_style) + logger = logInstance().getLogger("AppInitializer") if saved_custom_theme: try: - from managers.theme.ThemeManager import instance as themeInstance themeInstance().applyTheme(saved_custom_theme) except Exception: - pass + logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme) _applyTheme(saved_theme) def initializeApp( diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 95375a2..705e4fd 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -31,7 +31,10 @@ from PySide6.QtWidgets import ( ) import managers.config.ConfigManager as ConfigManager -from managers.theme.ThemeManager import instance as themeInstance +from managers.theme.ThemeManager import ( + ThemeManager, + instance as themeInstance +) from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget from interfaces.ConfigProvider import ( @@ -50,28 +53,6 @@ def _setActiveStyleName( global _active_style_name _active_style_name = name -def _clearCustomTheme( - theme: str -): - - app : QApplication | None = QApplication.instance() - if app: - app.setStyleSheet("") - _applyTheme(theme) - -def _applyCustomTheme( - name: str, - fallback_theme: str = "system" -): - - if not name: - _clearCustomTheme(fallback_theme) - return - try: - themeInstance().applyTheme(name) - except Exception: - _clearCustomTheme(fallback_theme) - def _applyTheme( theme: str ): @@ -94,19 +75,6 @@ def _restartApp( QApplication.instance().quit() QProcess.startDetached(sys.executable, sys.argv) -def _themeToReadable( - theme: str -) -> str: - - if theme == "dark": - return "深色" - elif theme == "light": - return "浅色" - elif theme == "both": - return "所有" - else: - return "未知" - class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): settingsWidgetIsClosed = Signal() @@ -136,8 +104,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.setNavigationIcons() self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) self.ThemeInfoLabel.setStyleSheet( - "border: 1px solid #ccc; " \ - "border-radius: 2px;" \ + "border: 1px solid palette(mid);"\ + "border-radius: 2px;"\ "padding: 5px;" ) @@ -256,7 +224,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( - f"{name} - 适用于 {_themeToReadable(need_theme)} 主题
" + f"{name} - 适用于 {ThemeManager.themeToReadable(need_theme)} 主题
" f"作者:{author}

" f"{brief}" ) @@ -299,7 +267,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): 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) - _applyCustomTheme(custom_theme, theme) + themeInstance().applyThemeOrClear(custom_theme, theme) self.syncRadioFromNeedTheme(custom_theme) theme, _, _ = self.collectSettings() self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) @@ -391,6 +359,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.DarkThemeRadio.setChecked(True) else: self.SystemThemeRadio.setChecked(True) + themeInstance().clearTheme(self.__original_theme) self.updateThemeInfo() @Slot() diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py index dcf68da..8984e4a 100644 --- a/src/managers/theme/ThemeManager.py +++ b/src/managers/theme/ThemeManager.py @@ -20,7 +20,8 @@ from managers.config.ConfigManager import instance as configInstance from utils.ThemeUtils import ( packTheme, readThemeInfo, - unpackTheme + unpackTheme, + wrapQssToAtheme ) @@ -85,14 +86,10 @@ class ThemeManager: ext = os.path.splitext(source_path)[1].lower() if ext == ".qss": name = os.path.splitext(os.path.basename(source_path))[0] - info = { - "name": name, - "author": "未知", - "need_theme": "both", - "brief": "没有相关简介" - } dest_path = os.path.join(self.__themes_dir, name + ".altheme") - packTheme(source_path, info, dest_path) + if os.path.exists(dest_path): + raise ValueError(f"主题 '{name}' 已存在") + wrapQssToAtheme(source_path, dest_path, "both") return name elif ext == ".altheme": with zipfile.ZipFile(source_path, "r") as zf: @@ -102,6 +99,8 @@ class ThemeManager: name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) safe_name = os.path.basename(name) dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") + if os.path.exists(dest_path): + raise ValueError(f"主题 '{safe_name}' 已存在") shutil.copy2(source_path, dest_path) return safe_name else: @@ -176,25 +175,108 @@ class ThemeManager: filepath = os.path.join(self.__themes_dir, name + ".altheme") if not os.path.isfile(filepath): raise FileNotFoundError(filepath) - info = readThemeInfo(filepath) - with tempfile.TemporaryDirectory() as tmpdir: - unpackTheme(filepath, tmpdir) - qss_path = os.path.join(tmpdir, "theme.qss") - if os.path.isfile(qss_path): - with open(qss_path, "r", encoding="utf-8") as fh: - qss = fh.read() - app = QApplication.instance() - if app: - app.setStyleSheet(qss) + with self.__lock: + info = readThemeInfo(filepath) + with tempfile.TemporaryDirectory() as tmpdir: + unpackTheme(filepath, tmpdir) + qss_path = os.path.join(tmpdir, "theme.qss") + if os.path.isfile(qss_path): + with open(qss_path, "r", encoding="utf-8") as fh: + qss = fh.read() + app = QApplication.instance() + if app: + app.setStyleSheet(qss) + app = QApplication.instance() + if app: + need_theme = info.get("need_theme", "both") + if need_theme == "dark": + app.styleHints().setColorScheme(Qt.ColorScheme.Dark) + elif need_theme == "light": + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + 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 app: - need_theme = info.get("need_theme", "both") - if need_theme == "dark": - app.styleHints().setColorScheme(Qt.ColorScheme.Dark) - elif need_theme == "light": - app.styleHints().setColorScheme(Qt.ColorScheme.Light) - with self.__lock: - self.__current_theme_name = name + app.setStyleSheet("") + self._applyColorScheme(theme) + + def applyThemeOrClear( + self, + name: str, + fallback_theme: str = "system" + ): + """ + Apply a custom theme by name, or clear to fallback if empty. + + Args: + name (str): The theme name to apply, or empty to clear. + fallback_theme (str): Color scheme to use if name is empty + or if the theme fails to apply. + """ + + if not name: + self.clearTheme(fallback_theme) + return + try: + self.applyTheme(name) + except Exception: + self.clearTheme(fallback_theme) + + def _applyColorScheme( + self, + theme: str + ): + """ + Set the Qt application color scheme. + + Args: + theme (str): "dark", "light", or any other value for system default. + """ + + app = QApplication.instance() + if not app: + return + if theme == "dark": + app.styleHints().setColorScheme(Qt.ColorScheme.Dark) + elif theme == "light": + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + else: + app.styleHints().setColorScheme(Qt.ColorScheme.Unknown) + + @staticmethod + def themeToReadable( + need_theme: str + ) -> str: + """ + Convert a need_theme code to human-readable Chinese text. + + Args: + need_theme (str): "dark", "light", "both", or other. + + Returns: + str: Readable Chinese label. + """ + + if need_theme == "dark": + return "深色" + elif need_theme == "light": + return "浅色" + elif need_theme == "both": + return "所有" + else: + return "未知" def currentThemeName( self diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py index 5946ef6..5e40598 100644 --- a/src/utils/ThemeUtils.py +++ b/src/utils/ThemeUtils.py @@ -89,7 +89,10 @@ def readThemeInfo( if "info.json" not in zf.namelist(): raise ValueError("无效的 .altheme: 缺少 info.json") with zf.open("info.json") as fh: - return json.loads(fh.read().decode("utf-8")) + info = json.loads(fh.read().decode("utf-8")) + if "name" not in info: + raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段") + return info def wrapQssToAtheme( From 44dbde3355f3844a44e191ff64a2b53d22435ea9 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sun, 31 May 2026 00:46:05 +0800 Subject: [PATCH 19/31] =?UTF-8?q?fix(theme):=20=E4=B8=BB=E9=A2=98=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=BA=A4=E5=8F=89=E5=AE=A1=E6=9F=A5=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动恢复: - _initializeAppearance 自定义主题加载失败时调用 clearTheme 回退配色方案 列表校验: - listThemes 同时校验 info.json 和 theme.qss 完整性 - 损坏的主题文件记录 LogManager 警告并跳过 - 按 (名称, 作者) 去重,同一作者同名主题仅保留一个 导入保护: - importTheme 新增 (名称, 作者) 冲突检查 - applyTheme 缺少 theme.qss 时抛出明确 ValueError 状态一致性: - saveAndApply 在 syncRadioFromNeedTheme 后重新采集 THEME 再保存 - __original_theme / __original_custom_theme 随每次 Apply 同步更新 - Reset 按钮恢复组合框到原始位置并刷新状态标签 代码质量: - 提取 _colorSchemeFor 静态方法消除 applyTheme/clearTheme 中的重复映射 - 移除未使用的 _applyTheme 死代码 - _active_style_name 默认值从 '' 改为 'Fusion' - 日志调用统一使用 LogManager - _applyCustomTheme 异常时通过 LogManager 记录详细错误 Co-Authored-By: Claude Opus 4.8 --- src/boot/AppInitializer.py | 17 +-- src/gui/ALSettingsWidget.py | 82 ++++++++------ src/managers/theme/ThemeManager.py | 176 +++++++++++++---------------- 3 files changed, 134 insertions(+), 141 deletions(-) diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index c90cbbf..039a163 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -12,15 +12,14 @@ import os from PySide6.QtCore import QStandardPaths, QDir from PySide6.QtWidgets import QApplication -from gui.ALSettingsWidget import ( - _setActiveStyleName, - _applyTheme, -) 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 instance as themeInstance +from managers.theme.ThemeManager import( + setActiveStyle, + instance as themeInstance +) def _initializeLogManager( @@ -82,14 +81,16 @@ def _initializeAppearance( saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") app.setStyle(saved_style) - _setActiveStyleName(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) - _applyTheme(saved_theme) + logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme) + themeInstance().clearTheme(saved_theme) + return + themeInstance().clearTheme(saved_theme) def initializeApp( ) -> bool: diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 705e4fd..ab11c84 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -31,8 +31,9 @@ from PySide6.QtWidgets import ( ) import managers.config.ConfigManager as ConfigManager -from managers.theme.ThemeManager import ( - ThemeManager, +from managers.log.LogManager import instance as logInstance +from managers.theme.ThemeManager import( + getActiveStyle, instance as themeInstance ) @@ -43,31 +44,34 @@ from interfaces.ConfigProvider import ( ) -_active_style_name = "" - - -def _setActiveStyleName( - name: str +def _applyCustomTheme( + name: str, + fallback_theme: str = "system" ): - global _active_style_name - _active_style_name = name - -def _applyTheme( - theme: str -): - - global _active_style_name - app : QApplication | None = QApplication.instance() - if not app: + if not name: + themeInstance().clearTheme(fallback_theme) return - if theme == "dark": - app.styleHints().setColorScheme(Qt.ColorScheme.Dark) - elif theme == "light": - app.styleHints().setColorScheme(Qt.ColorScheme.Light) + try: + themeInstance().applyTheme(name) + except Exception as e: + logInstance().getLogger("ALSettingsWidget").warning( + f"无法应用自定义主题 '{name}',回退到 {fallback_theme} 外观: {e}" + ) + themeInstance().clearTheme(fallback_theme) + +def _themeToReadable( + need_theme: str +) -> str: + + if need_theme == "dark": + return "深色" + elif need_theme == "light": + return "浅色" + elif need_theme == "both": + return "所有" else: - app.styleHints().setColorScheme(Qt.ColorScheme.Unknown) - app.setStyle(QStyleFactory.create(_active_style_name)) + return "未知" def _restartApp( ): @@ -75,6 +79,7 @@ def _restartApp( QApplication.instance().quit() QProcess.startDetached(sys.executable, sys.argv) + class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): settingsWidgetIsClosed = Signal() @@ -126,12 +131,6 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.StyleComboBox.clear() self.StyleComboBox.addItems(QStyleFactory.keys()) - def currentStyleKey( - self - ) -> str: - - return _active_style_name - def connectSignals( self ): @@ -181,7 +180,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") self.__original_theme = theme self.__original_custom_theme = custom_theme - self.__original_style = self.currentStyleKey() + self.__original_style = getActiveStyle() if theme == "light": self.LightThemeRadio.setChecked(True) elif theme == "dark": @@ -224,7 +223,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( - f"{name} - 适用于 {ThemeManager.themeToReadable(need_theme)} 主题
" + f"{name} - 适用于 {_themeToReadable(need_theme)} 主题
" f"作者:{author}

" f"{brief}" ) @@ -267,15 +266,18 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): 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) - themeInstance().applyThemeOrClear(custom_theme, theme) + _applyCustomTheme(custom_theme, 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) - _applyTheme(theme) self.setNavigationIcons() self.updateThemeStatus() self.updateThemeInfo() - self.__original_style = self.currentStyleKey() + self.__original_theme = theme + self.__original_custom_theme = custom_theme if custom_theme else "" + self.__original_style = getActiveStyle() def maybeRestart( self @@ -351,7 +353,14 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): self.ThemeComboBox.blockSignals(True) - self.ThemeComboBox.setCurrentIndex(0) + if self.__original_custom_theme: + idx = self.ThemeComboBox.findText(self.__original_custom_theme) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + else: + self.ThemeComboBox.setCurrentIndex(0) + else: + self.ThemeComboBox.setCurrentIndex(0) self.ThemeComboBox.blockSignals(False) if self.__original_theme == "light": self.LightThemeRadio.setChecked(True) @@ -359,7 +368,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.DarkThemeRadio.setChecked(True) else: self.SystemThemeRadio.setChecked(True) - themeInstance().clearTheme(self.__original_theme) + _applyCustomTheme(self.__original_custom_theme, self.__original_theme) + self.updateThemeStatus() self.updateThemeInfo() @Slot() diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py index 8984e4a..412e402 100644 --- a/src/managers/theme/ThemeManager.py +++ b/src/managers/theme/ThemeManager.py @@ -14,9 +14,13 @@ import threading import zipfile from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import ( + QApplication, + QStyleFactory +) from managers.config.ConfigManager import instance as configInstance +from managers.log.LogManager import instance as logInstance from utils.ThemeUtils import ( packTheme, readThemeInfo, @@ -25,6 +29,22 @@ from utils.ThemeUtils import ( ) +_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. @@ -46,6 +66,21 @@ class ThemeManager: 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 themesDir( self ) -> str: @@ -101,6 +136,14 @@ class ThemeManager: dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") if os.path.exists(dest_path): raise ValueError(f"主题 '{safe_name}' 已存在") + # Check for name collision with existing themes by the same author + new_author = info.get("author", "") + for existing in self.listThemes(): + if (existing.get("name", "") == safe_name + and existing.get("author", "") == new_author): + raise ValueError( + f"主题名称 '{safe_name}' (作者 '{new_author}') 已存在" + ) shutil.copy2(source_path, dest_path) return safe_name else: @@ -120,6 +163,7 @@ class ThemeManager: """ themes = [] + seen_keys = set() if not os.path.isdir(self.__themes_dir): return themes for filename in sorted(os.listdir(self.__themes_dir)): @@ -127,9 +171,27 @@ class ThemeManager: filepath = os.path.join(self.__themes_dir, filename) try: info = readThemeInfo(filepath) + with zipfile.ZipFile(filepath, "r") as zf: + if "theme.qss" not in zf.namelist(): + raise ValueError("缺少 theme.qss") + 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) themes.append(info) - except Exception: - pass + except Exception as e: + logInstance().getLogger("ThemeManager").warning( + f"无法读取主题文件 '{filename}',已跳过: {e}" + ) + else: + logInstance().getLogger("ThemeManager").warning( + f"未知文件类型 '{filename}',已跳过" + ) return themes def removeTheme( @@ -152,7 +214,7 @@ class ThemeManager: os.remove(filepath) if self.__current_theme_name == name: self.__current_theme_name = "" - self._clearQss() + self.clearTheme("system") def applyTheme( self, @@ -186,13 +248,17 @@ class ThemeManager: app = QApplication.instance() if app: app.setStyleSheet(qss) + else: + raise ValueError( + f"主题 '{name}' 的 .altheme 文件中缺少 theme.qss" + ) app = QApplication.instance() if app: need_theme = info.get("need_theme", "both") - if need_theme == "dark": - app.styleHints().setColorScheme(Qt.ColorScheme.Dark) - elif need_theme == "light": - app.styleHints().setColorScheme(Qt.ColorScheme.Light) + app.styleHints().setColorScheme( + ThemeManager._colorSchemeFor(need_theme) + ) + app.setStyle(QStyleFactory.create(_active_style_name)) self.__current_theme_name = name def clearTheme( @@ -207,99 +273,15 @@ class ThemeManager: ("light", "dark", or "system"). """ - app = QApplication.instance() - if app: - app.setStyleSheet("") - self._applyColorScheme(theme) - - def applyThemeOrClear( - self, - name: str, - fallback_theme: str = "system" - ): - """ - Apply a custom theme by name, or clear to fallback if empty. - - Args: - name (str): The theme name to apply, or empty to clear. - fallback_theme (str): Color scheme to use if name is empty - or if the theme fails to apply. - """ - - if not name: - self.clearTheme(fallback_theme) - return - try: - self.applyTheme(name) - except Exception: - self.clearTheme(fallback_theme) - - def _applyColorScheme( - self, - theme: str - ): - """ - Set the Qt application color scheme. - - Args: - theme (str): "dark", "light", or any other value for system default. - """ - app = QApplication.instance() if not app: return - if theme == "dark": - app.styleHints().setColorScheme(Qt.ColorScheme.Dark) - elif theme == "light": - app.styleHints().setColorScheme(Qt.ColorScheme.Light) - else: - app.styleHints().setColorScheme(Qt.ColorScheme.Unknown) + app.setStyleSheet("") + app.styleHints().setColorScheme( + ThemeManager._colorSchemeFor(theme) + ) + app.setStyle(QStyleFactory.create(_active_style_name)) - @staticmethod - def themeToReadable( - need_theme: str - ) -> str: - """ - Convert a need_theme code to human-readable Chinese text. - - Args: - need_theme (str): "dark", "light", "both", or other. - - Returns: - str: Readable Chinese label. - """ - - if need_theme == "dark": - return "深色" - elif need_theme == "light": - return "浅色" - elif need_theme == "both": - return "所有" - else: - return "未知" - - def currentThemeName( - self - ) -> str: - """ - Get the name of the currently active theme. - - Returns: - str: Current theme name, or empty string if none is active. - """ - - return self.__current_theme_name - - def _clearQss( - self - ): - """ - Clear the current QSS stylesheet from the application. - """ - - app = QApplication.instance() - if app: - app.setStyleSheet("") # ThemeManager singleton instance. _theme_manager_instance = None From b56d2c203e81fa82668d4db7ceb8b25cea14fd28 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sun, 31 May 2026 18:57:56 +0800 Subject: [PATCH 20/31] =?UTF-8?q?fix(theme):=20=E4=BF=AE=E5=A4=8D=20QSS=20?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E6=A0=B7=E5=BC=8F=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 QSpinBox/QDateEdit/QTimeEdit 的 ::up-button/::down-button/::up-arrow/::down-arrow 子控件样式,修复 spin button 箭头在 QSS 部分覆盖后退化渲染的问题 - 新增 QTreeWidget::indicator / QListWidget::indicator / QTableWidget::indicator 全状态样式,修复树控件中 CheckState 复选框因缺失 ::indicator 子控件而无法区分勾选状态的视觉 bug - 指示器勾选态颜色与主题色保持一致(BlueForest: #2dd4bf, LightLake: #0ea58a) - 同步深浅主题差异:移除 :hover:!selected 规则、统一 HeaderView padding、spin button 宽度及属性顺序 - up-arrow 注释 image:none 以还原原生箭头渲染 Co-Authored-By: Claude Opus 4.8 --- src/gui/resources/themes/BlueForest.qss | 152 ++++++++++++++++++++++-- src/gui/resources/themes/LightLake.qss | 148 ++++++++++++++++++++++- 2 files changed, 284 insertions(+), 16 deletions(-) diff --git a/src/gui/resources/themes/BlueForest.qss b/src/gui/resources/themes/BlueForest.qss index f6e10f1..0dfcaa3 100644 --- a/src/gui/resources/themes/BlueForest.qss +++ b/src/gui/resources/themes/BlueForest.qss @@ -7,7 +7,7 @@ * See the LICENSE file for details. * * - * AutoLibrary Official Style Theme : BlueForest + * AutoLibrary Official Theme : BlueForest */ /* ---- Global ---- */ @@ -116,6 +116,94 @@ 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 { + /* image: none; */ + 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 { + image: none; + 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 { @@ -158,6 +246,11 @@ QComboBox QAbstractItemView { selection-color: #0f1119; outline: none; } +QComboBox:disabled { + background-color: #162038; + color: #5568a0; + border-color: #1c2840; +} /* ---- Check Box / Radio Button ---- */ QCheckBox, @@ -193,6 +286,47 @@ 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 { @@ -238,10 +372,6 @@ QTabBar::tab:selected { color: #2dd4bf; border-bottom: 2px solid #2dd4bf; } -QTabBar::tab:hover:!selected { - background-color: #1c2840; - color: #d0daf0; -} /* ---- List / Tree ---- */ QListWidget, @@ -268,16 +398,12 @@ QTableWidget::item:selected { background-color: #2dd4bf; color: #0f1119; } -QListWidget::item:hover:!selected, -QTreeWidget::item:hover:!selected { - background-color: #1c2840; -} QHeaderView::section { background-color: #0f1628; border: none; border-right: 1px solid #253250; border-bottom: 1px solid #253250; - padding: 6px 10px; + padding: 5px 10px; color: #8b9ad0; font-weight: bold; } @@ -351,6 +477,12 @@ 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 { diff --git a/src/gui/resources/themes/LightLake.qss b/src/gui/resources/themes/LightLake.qss index beb3f3a..b5e0bc2 100644 --- a/src/gui/resources/themes/LightLake.qss +++ b/src/gui/resources/themes/LightLake.qss @@ -7,7 +7,7 @@ * See the LICENSE file for details. * * - * AutoLibrary Official Style Theme : LightLake + * AutoLibrary Official Theme : LightLake */ /* ---- Global ---- */ @@ -116,6 +116,94 @@ 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 { + /* image: none; */ + 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 { + image: none; + 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 { @@ -158,6 +246,11 @@ QComboBox QAbstractItemView { selection-color: #ffffff; outline: none; } +QComboBox:disabled { + background-color: #e8ecf2; + color: #98a8c0; + border-color: #d5dde8; +} /* ---- Check Box / Radio Button ---- */ QCheckBox, @@ -193,6 +286,47 @@ 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 { @@ -268,16 +402,12 @@ QTableWidget::item:selected { background-color: #0ea58a; color: #ffffff; } -QListWidget::item:hover:!selected, -QTreeWidget::item:hover:!selected { - background-color: #e2e8f0; -} QHeaderView::section { background-color: #dce4ee; border: none; border-right: 1px solid #c0cdda; border-bottom: 1px solid #c0cdda; - padding: 6px 10px; + padding: 5px 10px; color: #4a6080; font-weight: bold; } @@ -351,6 +481,12 @@ 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 { From 79e5b43498742e51754426f979ef420746a56681 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sun, 31 May 2026 18:58:07 +0800 Subject: [PATCH 21/31] =?UTF-8?q?fix(theme):=20=E4=BF=AE=E5=A4=8D=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - removeTheme() 删除当前活动主题后从 ConfigManager 读取已保存的主题偏好作为回退方案,不再硬编码 'system' - saveAndApply() 在调用 _applyCustomTheme 前先 setActiveStyle(style),确保主题应用时使用最新选择的内置样式 - _applyCustomTheme() 返回 bool 表示成败,失败时调用方清除配置中的 custom_theme 避免下次启动循环失败 - importTheme() 增加 self.__lock 保护,消除 TOCTOU 竞态条件 - ThemeManager 新增 CfgKey 导入以支持 removeTheme 读取配置 Co-Authored-By: Claude Opus 4.8 --- src/gui/ALSettingsWidget.py | 11 +++-- src/managers/theme/ThemeManager.py | 65 ++++++++++++++++-------------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index ab11c84..4450218 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -34,6 +34,7 @@ import managers.config.ConfigManager as ConfigManager from managers.log.LogManager import instance as logInstance from managers.theme.ThemeManager import( getActiveStyle, + setActiveStyle, instance as themeInstance ) @@ -47,18 +48,20 @@ from interfaces.ConfigProvider import ( def _applyCustomTheme( name: str, fallback_theme: str = "system" -): +) -> bool: if not name: themeInstance().clearTheme(fallback_theme) - return + 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 @@ -266,7 +269,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): 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) - _applyCustomTheme(custom_theme, 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 diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py index 412e402..3e77346 100644 --- a/src/managers/theme/ThemeManager.py +++ b/src/managers/theme/ThemeManager.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import ( 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 ( @@ -119,35 +120,36 @@ class ThemeManager: if not os.path.isfile(source_path): raise FileNotFoundError(source_path) ext = os.path.splitext(source_path)[1].lower() - if ext == ".qss": - name = os.path.splitext(os.path.basename(source_path))[0] - dest_path = os.path.join(self.__themes_dir, name + ".altheme") - if os.path.exists(dest_path): - raise ValueError(f"主题 '{name}' 已存在") - wrapQssToAtheme(source_path, dest_path, "both") - return name - elif ext == ".altheme": - with zipfile.ZipFile(source_path, "r") as zf: - if "theme.qss" not in zf.namelist(): - raise ValueError("无效的 .altheme: 缺少 theme.qss") - info = readThemeInfo(source_path) - name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) - safe_name = os.path.basename(name) - dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") - if os.path.exists(dest_path): - raise ValueError(f"主题 '{safe_name}' 已存在") - # Check for name collision with existing themes by the same author - new_author = info.get("author", "") - for existing in self.listThemes(): - if (existing.get("name", "") == safe_name - and existing.get("author", "") == new_author): - raise ValueError( - f"主题名称 '{safe_name}' (作者 '{new_author}') 已存在" - ) - shutil.copy2(source_path, dest_path) - return safe_name - else: - raise ValueError(f"不支持的文件类型: {ext}") + with self.__lock: + if ext == ".qss": + name = os.path.splitext(os.path.basename(source_path))[0] + dest_path = os.path.join(self.__themes_dir, name + ".altheme") + if os.path.exists(dest_path): + raise ValueError(f"主题 '{name}' 已存在") + wrapQssToAtheme(source_path, dest_path, "both") + return name + elif ext == ".altheme": + with zipfile.ZipFile(source_path, "r") as zf: + if "theme.qss" not in zf.namelist(): + raise ValueError("无效的 .altheme: 缺少 theme.qss") + info = readThemeInfo(source_path) + name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) + safe_name = os.path.basename(name) + dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") + if os.path.exists(dest_path): + raise ValueError(f"主题 '{safe_name}' 已存在") + # Check for name collision with existing themes by the same author + new_author = info.get("author", "") + for existing in self.listThemes(): + if (existing.get("name", "") == safe_name + and existing.get("author", "") == new_author): + raise ValueError( + f"主题名称 '{safe_name}' (作者 '{new_author}') 已存在" + ) + shutil.copy2(source_path, dest_path) + return safe_name + else: + raise ValueError(f"不支持的文件类型: {ext}") def listThemes( self @@ -214,7 +216,10 @@ class ThemeManager: os.remove(filepath) if self.__current_theme_name == name: self.__current_theme_name = "" - self.clearTheme("system") + saved_theme = configInstance().get( + CfgKey.GLOBAL.APPEARANCE.THEME, "system" + ) + self.clearTheme(saved_theme) def applyTheme( self, From 86f0761eed33f1ceb8448f7f2f1ba10affbaa50f Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sun, 7 Jun 2026 12:50:32 +0800 Subject: [PATCH 22/31] =?UTF-8?q?refactor(theme):=20=E4=BC=98=E5=8C=96=20L?= =?UTF-8?q?ightLake=20=E4=B8=8E=20BlueForest=20=E4=B8=BB=E9=A2=98=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/resources/themes/BlueForest.qss | 44 ++++++++--------------- src/gui/resources/themes/LightLake.qss | 48 ++++++++----------------- src/gui/resources/ui/ALConfigWidget.ui | 6 ++-- 3 files changed, 33 insertions(+), 65 deletions(-) diff --git a/src/gui/resources/themes/BlueForest.qss b/src/gui/resources/themes/BlueForest.qss index 0dfcaa3..0e6db26 100644 --- a/src/gui/resources/themes/BlueForest.qss +++ b/src/gui/resources/themes/BlueForest.qss @@ -21,11 +21,11 @@ QMainWindow::separator { QMenuBar { background-color: #0f1628; border-bottom: 1px solid #1c2840; - padding: 2px 6px; + padding: 2px 5px; color: #d0daf0; } QMenuBar::item { - padding: 4px 10px; + padding: 2px 10px; border-radius: 4px; } QMenuBar::item:selected { @@ -150,7 +150,6 @@ QSpinBox::up-arrow, QDoubleSpinBox::up-arrow, QDateEdit::up-arrow, QTimeEdit::up-arrow { - /* image: none; */ border-left: 4px solid transparent; border-right: 4px solid transparent; border-bottom: 5px solid #7888b8; @@ -176,7 +175,6 @@ QSpinBox::down-arrow, QDoubleSpinBox::down-arrow, QDateEdit::down-arrow, QTimeEdit::down-arrow { - image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #7888b8; @@ -255,7 +253,7 @@ QComboBox:disabled { /* ---- Check Box / Radio Button ---- */ QCheckBox, QRadioButton { - spacing: 8px; + spacing: 5px; color: #d0daf0; } QCheckBox::indicator, @@ -263,9 +261,14 @@ QRadioButton::indicator { border-style: solid; border-color: #334478; border-width: 2px; - border-radius: 3px; background-color: #0a1020; } +QCheckBox::indicator { + border-radius: 3px; +} +QRadioButton::indicator { + border-radius: 7px; +} QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: #2dd4bf; @@ -274,9 +277,6 @@ QCheckBox::indicator:checked { background-color: #2dd4bf; border-color: #2dd4bf; } -QRadioButton::indicator { - border-radius: 10px; -} QRadioButton::indicator:checked { background-color: #2dd4bf; border-color: #2dd4bf; @@ -330,20 +330,14 @@ QTableWidget::indicator:indeterminate { /* ---- 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: 6px; - margin-top: 12px; - padding-top: 14px; - color: #d0daf0; - font-weight: bold; -} -QGroupBox::title { - subcontrol-origin: margin; - left: 12px; - padding: 0 6px; - color: #8b9ad0; + border-radius: 5px; } /* ---- Tab ---- */ @@ -389,18 +383,10 @@ QTableWidget { QListWidget::item, QTreeWidget::item, QTableWidget::item { - padding: 5px 10px; - border: none; -} -QListWidget::item:selected, -QTreeWidget::item:selected, -QTableWidget::item:selected { - background-color: #2dd4bf; - color: #0f1119; + padding: 5px 5px; } QHeaderView::section { background-color: #0f1628; - border: none; border-right: 1px solid #253250; border-bottom: 1px solid #253250; padding: 5px 10px; diff --git a/src/gui/resources/themes/LightLake.qss b/src/gui/resources/themes/LightLake.qss index b5e0bc2..2ea0239 100644 --- a/src/gui/resources/themes/LightLake.qss +++ b/src/gui/resources/themes/LightLake.qss @@ -21,11 +21,11 @@ QMainWindow::separator { QMenuBar { background-color: #dce4ee; border-bottom: 1px solid #c0cdda; - padding: 2px 6px; + padding: 2px 5px; color: #1a2740; } QMenuBar::item { - padding: 4px 10px; + padding: 2px 10px; border-radius: 4px; } QMenuBar::item:selected { @@ -150,7 +150,6 @@ QSpinBox::up-arrow, QDoubleSpinBox::up-arrow, QDateEdit::up-arrow, QTimeEdit::up-arrow { - /* image: none; */ border-left: 4px solid transparent; border-right: 4px solid transparent; border-bottom: 5px solid #6a7898; @@ -176,7 +175,6 @@ QSpinBox::down-arrow, QDoubleSpinBox::down-arrow, QDateEdit::down-arrow, QTimeEdit::down-arrow { - image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #6a7898; @@ -255,7 +253,7 @@ QComboBox:disabled { /* ---- Check Box / Radio Button ---- */ QCheckBox, QRadioButton { - spacing: 8px; + spacing: 5px; color: #1a2740; } QCheckBox::indicator, @@ -263,9 +261,14 @@ QRadioButton::indicator { border-style: solid; border-color: #90a4c4; border-width: 2px; - border-radius: 3px; background-color: #ffffff; } +QCheckBox::indicator { + border-radius: 3px; +} +QRadioButton::indicator { + border-radius: 7px; +} QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: #0ea58a; @@ -274,9 +277,6 @@ QCheckBox::indicator:checked { background-color: #0ea58a; border-color: #0ea58a; } -QRadioButton::indicator { - border-radius: 10px; -} QRadioButton::indicator:checked { background-color: #0ea58a; border-color: #0ea58a; @@ -330,20 +330,14 @@ QTableWidget::indicator:indeterminate { /* ---- 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: 6px; - margin-top: 12px; - padding-top: 14px; - color: #1a2740; - font-weight: bold; -} -QGroupBox::title { - subcontrol-origin: margin; - left: 12px; - padding: 0 6px; - color: #4a6080; + border-radius: 5px; } /* ---- Tab ---- */ @@ -372,10 +366,6 @@ QTabBar::tab:selected { color: #0ea58a; border-bottom: 2px solid #0ea58a; } -QTabBar::tab:hover:!selected { - background-color: #d5dde8; - color: #1a2740; -} /* ---- List / Tree ---- */ QListWidget, @@ -393,18 +383,10 @@ QTableWidget { QListWidget::item, QTreeWidget::item, QTableWidget::item { - padding: 5px 10px; - border: none; -} -QListWidget::item:selected, -QTreeWidget::item:selected, -QTableWidget::item:selected { - background-color: #0ea58a; - color: #ffffff; + padding: 5px 5px; } QHeaderView::section { background-color: #dce4ee; - border: none; border-right: 1px solid #c0cdda; border-bottom: 1px solid #c0cdda; padding: 5px 10px; diff --git a/src/gui/resources/ui/ALConfigWidget.ui b/src/gui/resources/ui/ALConfigWidget.ui index a272576..c17f6c3 100644 --- a/src/gui/resources/ui/ALConfigWidget.ui +++ b/src/gui/resources/ui/ALConfigWidget.ui @@ -56,7 +56,7 @@
- 0 + 2 true @@ -1956,13 +1956,13 @@ - 100 + 120 25 - 100 + 120 25 From 67f297b43497ceba6d698725f620904ac8494a15 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sun, 7 Jun 2026 12:53:02 +0800 Subject: [PATCH 23/31] =?UTF-8?q?revert(ALConfigWidget.ui):=20=E6=92=A4?= =?UTF-8?q?=E5=9B=9E=E4=B8=8A=E6=AC=A1=E6=8F=90=E4=BA=A4=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=20ui=20=E6=96=87=E4=BB=B6=E7=9A=84=E5=90=AF=E5=8A=A8=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/resources/ui/ALConfigWidget.ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/resources/ui/ALConfigWidget.ui b/src/gui/resources/ui/ALConfigWidget.ui index c17f6c3..58db0b8 100644 --- a/src/gui/resources/ui/ALConfigWidget.ui +++ b/src/gui/resources/ui/ALConfigWidget.ui @@ -56,7 +56,7 @@ - 2 + 0 true From 007b4dc2efd5b23cd50a9cc246070072eed8847a Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 16 Jun 2026 18:37:47 +0800 Subject: [PATCH 24/31] =?UTF-8?q?fix(theme):=20=E4=BF=AE=E5=A4=8D=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E4=B8=BB=E9=A2=98=E6=97=A0=E6=B3=95=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E4=BD=9C=E8=80=85=E5=8F=8A=E5=AF=BC=E5=85=A5=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ThemeUtils.validateTheme 和 readThemeQss 集中校验与读取逻辑 - ThemeManager.importTheme 通过 _resolveDestPath 处理同名主题: 不同作者自动命名为 {主题名}_{作者名}.altheme, 首次导入保持原名 - ThemeManager.listThemes 返回 file 字段以便 UI 层定位文件 - ALSettingsWidget 全线改用 file 标识符, 组合框按作者消歧义显示 - 移除 applyTheme 中的临时目录解压, 改用 readThemeQss 直接读取 --- src/gui/ALSettingsWidget.py | 37 +++++++---- src/managers/theme/ThemeManager.py | 102 +++++++++++++++++------------ src/utils/ThemeUtils.py | 76 ++++++++++++++++++++- 3 files changed, 157 insertions(+), 58 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 4450218..f7f6666 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -196,7 +196,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.StyleComboBox.setCurrentIndex(index) self.populateThemeList() if custom_theme: - idx = self.ThemeComboBox.findText(custom_theme) + idx = self.ThemeComboBox.findData(custom_theme) if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) self.updateThemeStatus() @@ -206,8 +206,10 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self ): - name = self.ThemeComboBox.currentText() - if name and name != "默认": + file = self.ThemeComboBox.currentData() + t = self.__theme_cache.get(file) if file else None + name = t.get("name", "") if t else "" + if name: self.QssStatusLabel.setText(f"当前使用 {name} 主题。") else: self.QssStatusLabel.setText("当前使用 默认 主题。") @@ -216,12 +218,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self ): - name = self.ThemeComboBox.currentText() - if not name or name == "默认": + file = self.ThemeComboBox.currentData() + if not file: self.ThemeInfoLabel.setText("") return - t = self.__theme_cache.get(name) + 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", "没有相关简介") @@ -257,8 +260,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: theme = "system" style = self.StyleComboBox.currentText() - custom_theme = self.ThemeComboBox.currentText() - if custom_theme == "默认": + custom_theme = self.ThemeComboBox.currentData() or "" + if not custom_theme: custom_theme = "" return theme, style, custom_theme @@ -306,14 +309,20 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.ThemeComboBox.blockSignals(True) self.ThemeComboBox.clear() - self.ThemeComboBox.addItem("默认") + self.ThemeComboBox.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[name] = t - self.ThemeComboBox.addItem(name) + self.__theme_cache[file] = t + if author and author != "未知": + display = f"{name} ({author})" + else: + display = name + self.ThemeComboBox.addItem(display, file) self.ThemeComboBox.blockSignals(False) @Slot() @@ -330,9 +339,9 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if not file_path: return try: - name = themeInstance().importTheme(file_path) + file_id = themeInstance().importTheme(file_path) self.populateThemeList() - idx = self.ThemeComboBox.findText(name) + idx = self.ThemeComboBox.findData(file_id) if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) self.updateThemeStatus() @@ -359,7 +368,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.ThemeComboBox.blockSignals(True) if self.__original_custom_theme: - idx = self.ThemeComboBox.findText(self.__original_custom_theme) + idx = self.ThemeComboBox.findData(self.__original_custom_theme) if idx >= 0: self.ThemeComboBox.setCurrentIndex(idx) else: diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py index 3e77346..12d55f0 100644 --- a/src/managers/theme/ThemeManager.py +++ b/src/managers/theme/ThemeManager.py @@ -9,9 +9,7 @@ See the LICENSE file for details. """ import os import shutil -import tempfile import threading -import zipfile from PySide6.QtCore import Qt from PySide6.QtWidgets import ( @@ -23,9 +21,8 @@ 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 ( - packTheme, - readThemeInfo, - unpackTheme, + readThemeQss, + validateTheme, wrapQssToAtheme ) @@ -94,6 +91,54 @@ class ThemeManager: return self.__themes_dir + 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.removeTheme(theme_name) + 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 importTheme( self, source_path: str @@ -123,31 +168,17 @@ class ThemeManager: with self.__lock: if ext == ".qss": name = os.path.splitext(os.path.basename(source_path))[0] - dest_path = os.path.join(self.__themes_dir, name + ".altheme") - if os.path.exists(dest_path): - raise ValueError(f"主题 '{name}' 已存在") + dest_path = self._resolveDestPath(name, "未知") wrapQssToAtheme(source_path, dest_path, "both") - return name + return os.path.splitext(os.path.basename(dest_path))[0] elif ext == ".altheme": - with zipfile.ZipFile(source_path, "r") as zf: - if "theme.qss" not in zf.namelist(): - raise ValueError("无效的 .altheme: 缺少 theme.qss") - info = readThemeInfo(source_path) + info = validateTheme(source_path) name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) safe_name = os.path.basename(name) - dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") - if os.path.exists(dest_path): - raise ValueError(f"主题 '{safe_name}' 已存在") - # Check for name collision with existing themes by the same author new_author = info.get("author", "") - for existing in self.listThemes(): - if (existing.get("name", "") == safe_name - and existing.get("author", "") == new_author): - raise ValueError( - f"主题名称 '{safe_name}' (作者 '{new_author}') 已存在" - ) + dest_path = self._resolveDestPath(safe_name, new_author) shutil.copy2(source_path, dest_path) - return safe_name + return os.path.splitext(os.path.basename(dest_path))[0] else: raise ValueError(f"不支持的文件类型: {ext}") @@ -172,10 +203,7 @@ class ThemeManager: if filename.endswith(".altheme"): filepath = os.path.join(self.__themes_dir, filename) try: - info = readThemeInfo(filepath) - with zipfile.ZipFile(filepath, "r") as zf: - if "theme.qss" not in zf.namelist(): - raise ValueError("缺少 theme.qss") + info = validateTheme(filepath) name = info.get("name", "") author = info.get("author", "") key = (name, author) @@ -185,6 +213,7 @@ class ThemeManager: ) continue seen_keys.add(key) + info["file"] = os.path.splitext(filename)[0] themes.append(info) except Exception as e: logInstance().getLogger("ThemeManager").warning( @@ -243,22 +272,11 @@ class ThemeManager: if not os.path.isfile(filepath): raise FileNotFoundError(filepath) with self.__lock: - info = readThemeInfo(filepath) - with tempfile.TemporaryDirectory() as tmpdir: - unpackTheme(filepath, tmpdir) - qss_path = os.path.join(tmpdir, "theme.qss") - if os.path.isfile(qss_path): - with open(qss_path, "r", encoding="utf-8") as fh: - qss = fh.read() - app = QApplication.instance() - if app: - app.setStyleSheet(qss) - else: - raise ValueError( - f"主题 '{name}' 的 .altheme 文件中缺少 theme.qss" - ) + info = validateTheme(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) diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py index 5e40598..c45c5dc 100644 --- a/src/utils/ThemeUtils.py +++ b/src/utils/ThemeUtils.py @@ -37,7 +37,6 @@ def packTheme( 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 @@ -65,7 +64,6 @@ def unpackTheme( raise ValueError(f"不安全的 .altheme 入口: {name}") zf.extractall(output_dir) - def readThemeInfo( altheme_path: str ) -> dict: @@ -94,6 +92,80 @@ def readThemeInfo( raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段") return info +def readThemeQss( + altheme_path: str +) -> str: + """ + Read the theme.qss content directly from a .altheme archive. + + Args: + altheme_path (str): Path to the .altheme file. + + Returns: + str: The QSS stylesheet content. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If theme.qss is missing from the archive. + """ + + 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") + return zf.read("theme.qss").decode("utf-8") + +def validateTheme( + altheme_path: str +) -> dict: + """ + Validate a .altheme file and return its metadata. + + Checks that info.json and theme.qss both exist, info.json + contains all required fields with valid values, and theme.qss + is a non-empty entry in the archive. + + 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. + """ + + if not os.path.isfile(altheme_path): + raise FileNotFoundError(altheme_path) + with zipfile.ZipFile(altheme_path, "r") as zf: + names = zf.namelist() + if "info.json" not in names: + raise ValueError("无效的 .altheme: 缺少 info.json") + if "theme.qss" not in names: + raise ValueError("无效的 .altheme: 缺少 theme.qss") + info_bytes = zf.read("info.json") + qss_bytes = zf.read("theme.qss") + try: + info = json.loads(info_bytes.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' 字段") + if "author" not in info or not isinstance(info.get("author"), str): + 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' 字段") + if not qss_bytes.strip(): + raise ValueError("无效的 .altheme: theme.qss 为空") + return info def wrapQssToAtheme( qss_path: str, From 57f1cfb3f2b40cf3c81f0476b128fcdcfe3b2645 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 16 Jun 2026 19:37:09 +0800 Subject: [PATCH 25/31] =?UTF-8?q?fix(theme):=20=E4=BF=AE=E5=A4=8D=E6=AD=BB?= =?UTF-8?q?=E9=94=81=E3=80=81=E5=86=97=E4=BD=99=E8=AF=BB=E5=8F=96=E3=80=81?= =?UTF-8?q?=E7=A9=BA=E4=BD=9C=E8=80=85=E5=AD=97=E7=AC=A6=E4=B8=B2=E7=AD=89?= =?UTF-8?q?=E4=BA=A4=E5=8F=89=E5=AE=A1=E6=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThemeManager 拆分 _removeThemeFile 无锁版本, 消除 importTheme 持锁 时调用 removeTheme 导致的死锁 - validateTheme 增加 check_qss 参数, listThemes 跳过 QSS 读取 - validateTheme 拒绝空/空白作者字符串, 避免 info.json 与文件名不一致 - 统一默认作者为 "未知作者" - ALSettingsWidget.ui 增加删除按钮 [-], 浏览按钮改为 [+] - ALSettingsWidget 实现 onRemoveThemeButtonClicked 删除逻辑 --- src/gui/ALSettingsWidget.py | 46 ++++++++++++++++++++---- src/gui/resources/ui/ALSettingsWidget.ui | 21 ++++++++++- src/managers/theme/ThemeManager.py | 38 +++++++++++++------- src/utils/ThemeUtils.py | 15 +++++--- 4 files changed, 95 insertions(+), 25 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index f7f6666..b32aca5 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -139,6 +139,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): ): self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) + self.RemoveThemeButton.clicked.connect(self.onRemoveThemeButtonClicked) self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged) self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) @@ -225,7 +226,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): t = self.__theme_cache.get(file) if t: name = t.get("name", "未知") - author = t.get("author", "未知") + author = t.get("author", "未知作者") need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") self.ThemeInfoLabel.setText( @@ -318,13 +319,46 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): author = t.get("author", "") if name: self.__theme_cache[file] = t - if author and author != "未知": - display = f"{name} ({author})" - else: - display = name - self.ThemeComboBox.addItem(display, file) + self.ThemeComboBox.addItem(name, file) self.ThemeComboBox.blockSignals(False) + @Slot() + def onRemoveThemeButtonClicked( + self + ): + + file = self.ThemeComboBox.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.populateThemeList() + self.ThemeComboBox.setCurrentIndex(0) + self.updateThemeStatus() + self.updateThemeInfo() + except Exception as e: + QMessageBox.warning( + self, + "删除失败 - AutoLibrary", + f"无法删除主题:{e}" + ) + @Slot() def onImportThemeButtonClicked( self diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index a661738..413f8f6 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -328,7 +328,26 @@ - ... + + + + +
+ + + + + 25 + 25 + + + + + 25 + 25 + + + + - diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py index 12d55f0..12b0e81 100644 --- a/src/managers/theme/ThemeManager.py +++ b/src/managers/theme/ThemeManager.py @@ -121,7 +121,7 @@ class ThemeManager: existing_info = validateTheme(default_path) existing_author = existing_info.get("author", "") except Exception: - self.removeTheme(theme_name) + self._removeThemeFile(theme_name) # caller holds the lock raise ValueError( f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件" ) @@ -129,7 +129,7 @@ class ThemeManager: raise ValueError( f"主题名称 '{theme_name}' (作者 '{author}') 已存在" ) - safe_author = os.path.basename(author) if author else "未知" + safe_author = os.path.basename(author) if author else "未知作者" alt_path = os.path.join( self.__themes_dir, f"{theme_name}_{safe_author}.altheme" ) @@ -168,7 +168,7 @@ class ThemeManager: with self.__lock: if ext == ".qss": name = os.path.splitext(os.path.basename(source_path))[0] - dest_path = self._resolveDestPath(name, "未知") + dest_path = self._resolveDestPath(name, "未知作者") wrapQssToAtheme(source_path, dest_path, "both") return os.path.splitext(os.path.basename(dest_path))[0] elif ext == ".altheme": @@ -203,7 +203,7 @@ class ThemeManager: if filename.endswith(".altheme"): filepath = os.path.join(self.__themes_dir, filename) try: - info = validateTheme(filepath) + info = validateTheme(filepath, check_qss=False) # skip QSS read for list scan name = info.get("name", "") author = info.get("author", "") key = (name, author) @@ -225,6 +225,26 @@ class ThemeManager: ) return themes + def _removeThemeFile( + self, + name: str + ): + """ + Remove a theme file without locking. + + The caller must hold self.__lock before invoking this method. + """ + + filepath = os.path.join(self.__themes_dir, name + ".altheme") + if os.path.isfile(filepath): + os.remove(filepath) + if self.__current_theme_name == name: + self.__current_theme_name = "" + saved_theme = configInstance().get( + CfgKey.GLOBAL.APPEARANCE.THEME, "system" + ) + self.clearTheme(saved_theme) + def removeTheme( self, name: str @@ -239,16 +259,8 @@ class ThemeManager: name (str): The theme name to remove. """ - filepath = os.path.join(self.__themes_dir, name + ".altheme") with self.__lock: - if os.path.isfile(filepath): - os.remove(filepath) - if self.__current_theme_name == name: - self.__current_theme_name = "" - saved_theme = configInstance().get( - CfgKey.GLOBAL.APPEARANCE.THEME, "system" - ) - self.clearTheme(saved_theme) + self._removeThemeFile(name) def applyTheme( self, diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py index c45c5dc..305f201 100644 --- a/src/utils/ThemeUtils.py +++ b/src/utils/ThemeUtils.py @@ -117,7 +117,8 @@ def readThemeQss( return zf.read("theme.qss").decode("utf-8") def validateTheme( - altheme_path: str + altheme_path: str, + check_qss: bool = True ) -> dict: """ Validate a .altheme file and return its metadata. @@ -128,6 +129,8 @@ def validateTheme( Args: altheme_path (str): Path to the .altheme file. + check_qss (bool): If False, skip theme.qss existence and + content checks (for list-only operations). Returns: dict: The validated theme metadata dictionary. @@ -146,14 +149,16 @@ def validateTheme( if "theme.qss" not in names: raise ValueError("无效的 .altheme: 缺少 theme.qss") info_bytes = zf.read("info.json") - qss_bytes = zf.read("theme.qss") + qss_bytes = zf.read("theme.qss") if check_qss else None # skip QSS read when only listing try: info = json.loads(info_bytes.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' 字段") - if "author" not in info or not isinstance(info.get("author"), str): + # reject blank author so info.json does not drift from the "未知作者" filename fallback + 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"): @@ -163,7 +168,7 @@ def validateTheme( ) if "brief" not in info or not isinstance(info.get("brief"), str): raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段") - if not qss_bytes.strip(): + if check_qss and not qss_bytes.strip(): raise ValueError("无效的 .altheme: theme.qss 为空") return info @@ -191,7 +196,7 @@ def wrapQssToAtheme( filename = os.path.splitext(os.path.basename(qss_path))[0] info = { "name": filename, - "author": "未知", + "author": "未知作者", "need_theme": current_theme, "brief": "没有相关简介" } From 8e1b28f3fedc2b9aafa98ba16720175a1eefa241 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Tue, 16 Jun 2026 22:19:05 +0800 Subject: [PATCH 26/31] =?UTF-8?q?fix:=20requirements.txt=20=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E4=BB=8E=20UTF-16=20LE=20=E8=BD=AC=E4=B8=BA=20UTF-8?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=208=20=E4=B8=AA=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除的包: altgraph, mpmath, pefile, pyinstaller-hooks-contrib, pywin32-ctypes, setuptools, sympy, websocket-client (这些均为传递依赖,pip 会根据直接依赖自动解析安装) Co-Authored-By: Claude --- requirements.txt | Bin 1588 -> 588 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 95204cc9a1eb0e474262b145072cb7b46c881a72..18dddf621a6035fc20b3ce8a5da07e6b823b0911 100644 GIT binary patch literal 588 zcmY*W!BXrX5WMp*P6d*Udf?!}yOtkdk?h2!mW2qDiu=C)HLUfXa-s*Or@P0A&+dX=Z0%eR>UjIZ+Y z&wJ+2jf>R9P;cm<3I~pUyI6~89-yb3{FKfd|0^odV^yj0BUV*X7Cz1?iu3Wd2gKSb z$@Nc1JpydtZs(~$8B1FZmagQwBF=aI5{|_;e*@_Q8nzsQpZcn6#E$mT(!I?ze*tJ@ Bu(ALE literal 1588 zcmai!QBT`I5QO)+Qh!RK+J-bd^nr)IQ}qX+Bm@%S*v4@L>5p$a-|iiD)T)(*0pH&2 z%=vytsAC3=hr5#uLS$N+Ei>DQX-uo7g znAab*8|G^}d1qK=1=p%9#EkciqwVxp;V7XVjiGO^K8=tv1M1LS3%5((L0bCPgTBFh zNM6DT-htI<4;5!eV;6cV-`T25V{c-4(r^4cNI6}>D82G3Ri3oIkxSEx=rR|2E1g5? z2*Zs%>-{9eM(>!=sIOBO=2E9_F+F%Ky>GbPc||Sk*TgB@!$I8W#Jg43Hu+5#5>z?w zP>=4l*S-3z^E|RgWkNcOF}x!YG278YP*8$K5(4k2iCo^O>)7wl%9$q9>UkEz%w5Fo zUYP6TO-^sjy#25KtLn&pOdU=C+DG2uo|A#g zc?M@q2gQ|`p=}U%<{Q<)IOpw~R&Q0$>SdCceJG;_b&;8AT6^t#QBv7GBzzJ<=X`IV ziRm=rMD9U}ypyb$QJi^B64`0rwJ#kf&La6bYHCb~xqC!2hFjrv%qq_4 zy{g`vCmGs1)0iRt6}nM#my`5x2JohGTo6fIq?h?#_9hq9N8WOZ##6)D98YszM_2It E4TSaQAOHXW From f9175371dcc2159bd0d56c2a658226626e5bec11 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Wed, 17 Jun 2026 08:15:03 +0800 Subject: [PATCH 27/31] =?UTF-8?q?feat(gui):=20+/-=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=9B=BF=E6=8D=A2=E4=B8=BA=20QtAwesome=20?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=8Cfa5s=20=E7=BB=9F=E4=B8=80=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E4=B8=BA=20fa6s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALSettingsWidget: BrowseQssButton/RemoveThemeButton 的 + / - 文本改为 fa6s.plus/fa6s.minus 图标 - ALAutoScriptEditDialog: ZoomInBtn/ZoomOutBtn 的全角 +/- 改为 fa6s.plus/fa6s.minus 图标 - 其余图标同步从 fa5s 升级至 fa6s (Font Awesome 6) Co-Authored-By: Claude --- src/gui/ALAutoScriptEditDialog.py | 12 ++++++++---- src/gui/ALSettingsWidget.py | 9 ++++++++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/gui/ALAutoScriptEditDialog.py b/src/gui/ALAutoScriptEditDialog.py index 0630c5e..2a32510 100644 --- a/src/gui/ALAutoScriptEditDialog.py +++ b/src/gui/ALAutoScriptEditDialog.py @@ -211,12 +211,16 @@ 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(qta.icon("fa5s.undo", color=self._iconColor())) + 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("重置缩放") @@ -241,7 +245,7 @@ class ALAutoScriptEditDialog(QDialog): ToolbarLayout.addWidget(self.ZoomLabel) ToolbarLayout.addStretch() self.CopyBtn = QPushButton("") - self.CopyBtn.setIcon(qta.icon("fa5s.copy", color=self._iconColor())) + self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor())) self.CopyBtn.setIconSize(QSize(14, 14)) self.CopyBtn.setFixedSize(25, 25) self.CopyBtn.setToolTip("复制脚本") diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index b32aca5..3c0cbc0 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -110,6 +110,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.NavigationList.setCurrentRow(0) self.populateStyles() self.setNavigationIcons() + color = QApplication.instance().palette().color( + QApplication.instance().palette().ColorRole.WindowText + ).name() + self.BrowseQssButton.setIcon(qta.icon("fa6s.plus", color=color)) + self.BrowseQssButton.setText("") + self.RemoveThemeButton.setIcon(qta.icon("fa6s.minus", color=color)) + self.RemoveThemeButton.setText("") self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) self.ThemeInfoLabel.setStyleSheet( "border: 1px solid palette(mid);"\ @@ -125,7 +132,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): color = app.palette().color(app.palette().ColorRole.WindowText).name() item = self.NavigationList.item(0) if item: - item.setIcon(qta.icon("fa5s.palette", color=color)) + item.setIcon(qta.icon("fa6s.palette", color=color)) def populateStyles( self From 5552af134533ac957978d931da8143a09a3787be Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Fri, 19 Jun 2026 09:36:18 +0800 Subject: [PATCH 28/31] =?UTF-8?q?refactor(gui):=20=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E6=8C=89=E9=92=AE=E9=87=8D=E5=A4=8D=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E9=87=8D=E7=BD=AE=E6=8C=89=E9=92=AE=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8F=90=E5=89=8D=E5=BA=94=E7=94=A8=E4=B8=BB=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/ALSettingsWidget.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 3c0cbc0..8c0711e 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -423,7 +423,6 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.DarkThemeRadio.setChecked(True) else: self.SystemThemeRadio.setChecked(True) - _applyCustomTheme(self.__original_custom_theme, self.__original_theme) self.updateThemeStatus() self.updateThemeInfo() @@ -450,9 +449,5 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self ): - _, style, _ = self.collectSettings() - style_changed = self.__original_style != style - self.saveAndApply() - if style_changed: - self.maybeRestart() + self.onApplyButtonClicked() # virtually call apply button clicked self.close() From 88a74a7a47c25bd6b38432f0218e3139019cc686 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Fri, 19 Jun 2026 10:20:35 +0800 Subject: [PATCH 29/31] =?UTF-8?q?refactor(gui):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E5=B1=85=E4=B8=AD=E9=80=BB=E8=BE=91=E8=87=B3?= =?UTF-8?q?=20CenterOnParentMixin=EF=BC=8C=E6=B6=88=E9=99=A45=E5=A4=84?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=20showEvent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/ALConfigWidget.py | 26 ++------------- src/gui/ALSeatMapSelectDialog.py | 27 ++------------- src/gui/ALSettingsWidget.py | 43 +++++++----------------- src/gui/ALTimerTaskManageWidget.py | 26 ++------------- src/gui/ALWebDriverDownloadDialog.py | 25 ++------------ src/gui/ALWidgetMixin.py | 49 ++++++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 128 deletions(-) create mode 100644 src/gui/ALWidgetMixin.py 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/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 index 8c0711e..fd3d786 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -19,8 +19,7 @@ from PySide6.QtCore import ( Slot ) from PySide6.QtGui import ( - QCloseEvent, - QShowEvent + QCloseEvent ) from PySide6.QtWidgets import ( QApplication, @@ -38,6 +37,7 @@ from managers.theme.ThemeManager import( instance as themeInstance ) +from gui.ALWidgetMixin import CenterOnParentMixin from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget from interfaces.ConfigProvider import ( CfgKey, @@ -83,7 +83,7 @@ def _restartApp( QProcess.startDetached(sys.executable, sys.argv) -class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): +class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): settingsWidgetIsClosed = Signal() @@ -102,6 +102,14 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.connectSignals() self.loadSettings() + def closeEvent( + self, + event: QCloseEvent + ): + + self.settingsWidgetIsClosed.emit() + super().closeEvent(event) + def modifyUi( self ): @@ -153,35 +161,6 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.ApplyButton.clicked.connect(self.onApplyButtonClicked) self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) - def showEvent( - self, - event: QShowEvent - ): - - 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 - ): - - self.settingsWidgetIsClosed.emit() - super().closeEvent(event) - def loadSettings( self ): 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) From 8f8e3e4ba7f6d84b34039040f32cbbb7a8207134 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Fri, 19 Jun 2026 10:22:36 +0800 Subject: [PATCH 30/31] =?UTF-8?q?refactor(gui):=20=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E4=B8=BB=E9=A2=98=E6=8E=A7=E4=BB=B6=E5=8F=8A=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E9=87=8D=E5=91=BD=E5=90=8D=EF=BC=8C=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=20CustomTheme=20=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/ALSettingsWidget.py | 157 +++---- src/gui/resources/ui/ALSettingsWidget.ui | 546 +++++++++++------------ 2 files changed, 344 insertions(+), 359 deletions(-) diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index fd3d786..7dab1f1 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -115,22 +115,23 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): ): self.setWindowFlags(Qt.WindowType.Window) - self.NavigationList.setCurrentRow(0) - self.populateStyles() self.setNavigationIcons() color = QApplication.instance().palette().color( QApplication.instance().palette().ColorRole.WindowText ).name() - self.BrowseQssButton.setIcon(qta.icon("fa6s.plus", color=color)) - self.BrowseQssButton.setText("") - self.RemoveThemeButton.setIcon(qta.icon("fa6s.minus", color=color)) - self.RemoveThemeButton.setText("") - self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) - self.ThemeInfoLabel.setStyleSheet( + 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 @@ -149,14 +150,32 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): 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.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) - self.RemoveThemeButton.clicked.connect(self.onRemoveThemeButtonClicked) - self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged) - self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked) + 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) @@ -181,33 +200,20 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): if index < 0: index = 0 self.StyleComboBox.setCurrentIndex(index) - self.populateThemeList() if custom_theme: - idx = self.ThemeComboBox.findData(custom_theme) + idx = self.CustomThemeComboBox.findData(custom_theme) if idx >= 0: - self.ThemeComboBox.setCurrentIndex(idx) - self.updateThemeStatus() - self.updateThemeInfo() + self.CustomThemeComboBox.setCurrentIndex(idx) + self.updateCustomThemeInfo() + self.updateCustomThemeStatus() - def updateThemeStatus( + def updateCustomThemeInfo( self ): - file = self.ThemeComboBox.currentData() - t = self.__theme_cache.get(file) if file else None - name = t.get("name", "") if t else "" - if name: - self.QssStatusLabel.setText(f"当前使用 {name} 主题。") - else: - self.QssStatusLabel.setText("当前使用 默认 主题。") - - def updateThemeInfo( - self - ): - - file = self.ThemeComboBox.currentData() + file = self.CustomThemeComboBox.currentData() if not file: - self.ThemeInfoLabel.setText("") + self.CustomThemeInfoLabel.setText("") return t = self.__theme_cache.get(file) if t: @@ -215,13 +221,25 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): author = t.get("author", "未知作者") need_theme = t.get("need_theme", "both") brief = t.get("brief", "没有相关简介") - self.ThemeInfoLabel.setText( + self.CustomThemeInfoLabel.setText( f"{name} - 适用于 {_themeToReadable(need_theme)} 主题
" f"作者:{author}

" f"{brief}" ) else: - self.ThemeInfoLabel.setText("") + 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, @@ -247,7 +265,7 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): else: theme = "system" style = self.StyleComboBox.currentText() - custom_theme = self.ThemeComboBox.currentData() or "" + custom_theme = self.CustomThemeComboBox.currentData() or "" if not custom_theme: custom_theme = "" return theme, style, custom_theme @@ -268,8 +286,8 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): theme, _, _ = self.collectSettings() self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) self.setNavigationIcons() - self.updateThemeStatus() - self.updateThemeInfo() + self.updateCustomThemeStatus() + self.updateCustomThemeInfo() self.__original_theme = theme self.__original_custom_theme = custom_theme if custom_theme else "" self.__original_style = getActiveStyle() @@ -290,30 +308,12 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): return True return False - def populateThemeList( - self - ): - - self.ThemeComboBox.blockSignals(True) - self.ThemeComboBox.clear() - self.ThemeComboBox.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.ThemeComboBox.addItem(name, file) - self.ThemeComboBox.blockSignals(False) - @Slot() - def onRemoveThemeButtonClicked( + def onRemoveCustomThemeButtonClicked( self ): - file = self.ThemeComboBox.currentData() + file = self.CustomThemeComboBox.currentData() if not file: QMessageBox.information( self, @@ -334,10 +334,10 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): return try: themeInstance().removeTheme(file) - self.populateThemeList() - self.ThemeComboBox.setCurrentIndex(0) - self.updateThemeStatus() - self.updateThemeInfo() + self.populateCustomThemes() + self.CustomThemeComboBox.setCurrentIndex(0) + self.updateCustomThemeStatus() + self.updateCustomThemeInfo() except Exception as e: QMessageBox.warning( self, @@ -346,7 +346,7 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): ) @Slot() - def onImportThemeButtonClicked( + def onImportCustomThemeButtonClicked( self ): @@ -360,12 +360,12 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): return try: file_id = themeInstance().importTheme(file_path) - self.populateThemeList() - idx = self.ThemeComboBox.findData(file_id) + self.populateCustomThemes() + idx = self.CustomThemeComboBox.findData(file_id) if idx >= 0: - self.ThemeComboBox.setCurrentIndex(idx) - self.updateThemeStatus() - self.updateThemeInfo() + self.CustomThemeComboBox.setCurrentIndex(idx) + self.updateCustomThemeStatus() + self.updateCustomThemeInfo() except Exception as e: QMessageBox.warning( self, @@ -374,36 +374,37 @@ class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget): ) @Slot() - def onThemeComboBoxChanged( + def onCustomThemeComboBoxChanged( self, index: int ): - self.updateThemeInfo() + self.updateCustomThemeInfo() + # no status update, because custom theme is not applied yet. @Slot() - def onResetThemeButtonClicked( + def onResetCustomThemeButtonClicked( self ): - self.ThemeComboBox.blockSignals(True) + self.CustomThemeComboBox.blockSignals(True) if self.__original_custom_theme: - idx = self.ThemeComboBox.findData(self.__original_custom_theme) + idx = self.CustomThemeComboBox.findData(self.__original_custom_theme) if idx >= 0: - self.ThemeComboBox.setCurrentIndex(idx) + self.CustomThemeComboBox.setCurrentIndex(idx) else: - self.ThemeComboBox.setCurrentIndex(0) + self.CustomThemeComboBox.setCurrentIndex(0) else: - self.ThemeComboBox.setCurrentIndex(0) - self.ThemeComboBox.blockSignals(False) + 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.updateThemeStatus() - self.updateThemeInfo() + self.updateCustomThemeInfo() + self.updateCustomThemeStatus() @Slot() def onCancelButtonClicked( diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui index 413f8f6..45ed0e8 100644 --- a/src/gui/resources/ui/ALSettingsWidget.ui +++ b/src/gui/resources/ui/ALSettingsWidget.ui @@ -115,9 +115,9 @@ 0 - 0 - 450 - 380 + -51 + 397 + 434 @@ -138,324 +138,308 @@ - - 主题模式 - - - - 5 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - 浅色 - - - - - - - 深色 - - - - - - - 跟随系统 - - - true - - - - - - - - - - 界面风格 - - - - 5 - - - 3 - - - 3 - - - 3 - - - 3 - - - + + 主题模式 + + 5 + + 3 + + + 3 + + + 3 + + + 3 + - - - - 100 - 25 - - - - - 100 - 25 - - + - 应用程序样式: + 浅色 - - - - 160 - 25 - + + + 深色 + + + + + + + 跟随系统 + + + true - - - - - 更改样式将在下次启动应用程序时生效。 - - - - - - - - - - 自定义外观 - - - - 5 - - - 3 - - - 3 - - - 3 - - - 3 - - - - - 选择一个主题,或导入新的主题文件: - - - true - - - - - + + + + + + 界面风格 + + 5 + + 3 + + + 3 + + + 3 + + + 3 + - - - - 160 - 25 - + + + 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 - 25 + 60 - - false + + - - 选择或输入 QSS 样式表文件路径... + + Qt::TextFormat::RichText + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + true - - - - 25 - 25 - + + + 5 - - - 25 - 25 - - - - + - - + + + + + 80 + 25 + + + + 重置主题 + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + - - - - 25 - 25 - - - - - 25 - 25 - - + - - + 当前使用程序 默认 外观。 + + + true - - - - - - 0 - 60 - - - - - - - Qt::TextFormat::RichText - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop - - - true - - - - - - - 5 - - - - - - 80 - 25 - - - - false - - - 应用样式 - - - - - - - - 80 - 25 - - - - 重置主题 - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - - 当前使用程序 默认 外观。 - - - true - - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + - - - - + + + From c250fa4a6e3e2645eb39df2a87c4df99f2991976 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Fri, 19 Jun 2026 11:21:50 +0800 Subject: [PATCH 31/31] =?UTF-8?q?refactor(theme):=20=E5=B0=86=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E4=B8=BB=E9=A2=98=E9=80=BB=E8=BE=91=E4=B8=8B?= =?UTF-8?q?=E6=B2=89=E8=87=B3=20ThemeUtils=EF=BC=8C=E6=B6=88=E9=99=A4=20va?= =?UTF-8?q?lidateTheme=20=E8=81=8C=E8=B4=A3=E8=BF=87=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/managers/theme/ThemeManager.py | 76 ++++++++-------- src/utils/ThemeUtils.py | 135 ++++++++++++++--------------- 2 files changed, 103 insertions(+), 108 deletions(-) diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py index 12b0e81..755c67b 100644 --- a/src/managers/theme/ThemeManager.py +++ b/src/managers/theme/ThemeManager.py @@ -21,6 +21,7 @@ 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 @@ -79,17 +80,21 @@ class ThemeManager: else: return Qt.ColorScheme.Unknown - def themesDir( - self - ) -> str: + def _deleteThemeFile( + self, + name: str + ): """ - Get the themes directory path. + Delete a theme file in the themes storage directory. - Returns: - str: The absolute path to the themes storage directory. + The caller must hold self.__lock before invoking this method. + + **This method ONLY deletes the file**. """ - return self.__themes_dir + filepath = os.path.join(self.__themes_dir, name + ".altheme") + if os.path.isfile(filepath): + os.remove(filepath) def _resolveDestPath( self, @@ -121,7 +126,7 @@ class ThemeManager: existing_info = validateTheme(default_path) existing_author = existing_info.get("author", "") except Exception: - self._removeThemeFile(theme_name) # caller holds the lock + self._deleteThemeFile(theme_name) # caller holds the lock raise ValueError( f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件" ) @@ -139,6 +144,18 @@ class ThemeManager: ) 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 @@ -164,16 +181,16 @@ class ThemeManager: if not os.path.isfile(source_path): raise FileNotFoundError(source_path) - ext = os.path.splitext(source_path)[1].lower() + base_name, ext = os.path.splitext(os.path.basename(source_path)) + ext = ext.lower() with self.__lock: if ext == ".qss": - name = os.path.splitext(os.path.basename(source_path))[0] - dest_path = self._resolveDestPath(name, "未知作者") + 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", os.path.splitext(os.path.basename(source_path))[0]) + 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) @@ -203,7 +220,7 @@ class ThemeManager: if filename.endswith(".altheme"): filepath = os.path.join(self.__themes_dir, filename) try: - info = validateTheme(filepath, check_qss=False) # skip QSS read for list scan + info = validateTheme(filepath) name = info.get("name", "") author = info.get("author", "") key = (name, author) @@ -225,26 +242,6 @@ class ThemeManager: ) return themes - def _removeThemeFile( - self, - name: str - ): - """ - Remove a theme file without locking. - - The caller must hold self.__lock before invoking this method. - """ - - filepath = os.path.join(self.__themes_dir, name + ".altheme") - if os.path.isfile(filepath): - os.remove(filepath) - if self.__current_theme_name == name: - self.__current_theme_name = "" - saved_theme = configInstance().get( - CfgKey.GLOBAL.APPEARANCE.THEME, "system" - ) - self.clearTheme(saved_theme) - def removeTheme( self, name: str @@ -253,14 +250,21 @@ class ThemeManager: Remove a theme by name. If the removed theme is currently active, clears the QSS - stylesheet from the application. + stylesheet from the application and reverts to the saved + colour scheme. Args: name (str): The theme name to remove. """ with self.__lock: - self._removeThemeFile(name) + 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, @@ -284,7 +288,7 @@ class ThemeManager: if not os.path.isfile(filepath): raise FileNotFoundError(filepath) with self.__lock: - info = validateTheme(filepath) + info = readThemeInfo(filepath) qss = readThemeQss(filepath) app = QApplication.instance() if app: diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py index 305f201..94ed0d0 100644 --- a/src/utils/ThemeUtils.py +++ b/src/utils/ThemeUtils.py @@ -68,17 +68,20 @@ def readThemeInfo( altheme_path: str ) -> dict: """ - Read only the info.json metadata from a .altheme file. + 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 theme metadata dictionary. + dict: The validated theme metadata dictionary. Raises: FileNotFoundError: If altheme_path does not exist. - ValueError: If the .altheme does not contain info.json. + ValueError: If info.json is missing or any field is invalid. """ if not os.path.isfile(altheme_path): @@ -87,76 +90,14 @@ def readThemeInfo( if "info.json" not in zf.namelist(): raise ValueError("无效的 .altheme: 缺少 info.json") with zf.open("info.json") as fh: - info = json.loads(fh.read().decode("utf-8")) - if "name" not in info: - raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段") - return info - -def readThemeQss( - altheme_path: str -) -> str: - """ - Read the theme.qss content directly from a .altheme archive. - - Args: - altheme_path (str): Path to the .altheme file. - - Returns: - str: The QSS stylesheet content. - - Raises: - FileNotFoundError: If altheme_path does not exist. - ValueError: If theme.qss is missing from the archive. - """ - - 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") - return zf.read("theme.qss").decode("utf-8") - -def validateTheme( - altheme_path: str, - check_qss: bool = True -) -> dict: - """ - Validate a .altheme file and return its metadata. - - Checks that info.json and theme.qss both exist, info.json - contains all required fields with valid values, and theme.qss - is a non-empty entry in the archive. - - Args: - altheme_path (str): Path to the .altheme file. - check_qss (bool): If False, skip theme.qss existence and - content checks (for list-only operations). - - Returns: - dict: The validated theme metadata dictionary. - - Raises: - FileNotFoundError: If altheme_path does not exist. - ValueError: If validation fails for any reason. - """ - - if not os.path.isfile(altheme_path): - raise FileNotFoundError(altheme_path) - with zipfile.ZipFile(altheme_path, "r") as zf: - names = zf.namelist() - if "info.json" not in names: - raise ValueError("无效的 .altheme: 缺少 info.json") - if "theme.qss" not in names: - raise ValueError("无效的 .altheme: 缺少 theme.qss") - info_bytes = zf.read("info.json") - qss_bytes = zf.read("theme.qss") if check_qss else None # skip QSS read when only listing - try: - info = json.loads(info_bytes.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError) as e: - raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}") + 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 info.json does not drift from the "未知作者" filename fallback + # 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' 字段") @@ -168,8 +109,58 @@ def validateTheme( ) if "brief" not in info or not isinstance(info.get("brief"), str): raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段") - if check_qss and not qss_bytes.strip(): + 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(