diff --git a/requirements.txt b/requirements.txt
index b9d0351..18dddf6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,33 +7,28 @@ flatbuffers==25.12.19
h11==0.16.0
idna==3.11
lupa==2.8
-mpmath==1.3.0
numpy==2.4.3
onnxruntime==1.24.4
outcome==1.3.0.post0
packaging==26.0
-pefile==2024.8.26
pillow==12.1.1
protobuf==7.34.0
pybrowsers==1.3.2
pycparser==3.0
pyinstaller==6.19.0
-pyinstaller-hooks-contrib==2026.3
PySide6==6.10.2
PySide6_Addons==6.10.2
PySide6_Essentials==6.10.2
PySocks==1.7.1
-pywin32-ctypes==0.2.3
+QtAwesome==1.4.2
+QtPy==2.4.3
requests==2.32.5
selenium==4.38.0
-setuptools==82.0.1
shiboken6==6.10.2
sniffio==1.3.1
sortedcontainers==2.4.0
-sympy==1.14.0
trio==0.33.0
trio-websocket==0.12.2
typing_extensions==4.15.0
urllib3==2.6.3
-websocket-client==1.9.0
-wsproto==1.3.2
+wsproto==1.3.2
\ No newline at end of file
diff --git a/src/Main.py b/src/Main.py
index c9e50ca..cf35f22 100644
--- a/src/Main.py
+++ b/src/Main.py
@@ -24,7 +24,6 @@ def main():
translator = QTranslator()
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
app.installTranslator(translator)
- app.setStyle("Fusion")
app.setApplicationName("AutoLibrary")
if not initializeApp():
sys.exit(-1)
diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py
index 4512368..039a163 100644
--- a/src/boot/AppInitializer.py
+++ b/src/boot/AppInitializer.py
@@ -10,10 +10,16 @@ See the LICENSE file for details.
import os
from PySide6.QtCore import QStandardPaths, QDir
+from PySide6.QtWidgets import QApplication
-from managers.log.LogManager import instance as logInstance
+from interfaces.ConfigProvider import CfgKey
from managers.config.ConfigManager import instance as configInstance
from managers.driver.WebDriverManager import instance as webdriverInstance
+from managers.log.LogManager import instance as logInstance
+from managers.theme.ThemeManager import(
+ setActiveStyle,
+ instance as themeInstance
+)
def _initializeLogManager(
@@ -64,13 +70,35 @@ def _initializeWebDriverManager(
webdriverInstance(driver_dir)
return True
+def _initializeAppearance(
+):
+
+ app = QApplication.instance()
+ if not app:
+ return
+ cfg = configInstance()
+ saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
+ saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
+ saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
+ app.setStyle(saved_style)
+ setActiveStyle(saved_style)
+ logger = logInstance().getLogger("AppInitializer")
+ if saved_custom_theme:
+ try:
+ themeInstance().applyTheme(saved_custom_theme)
+ except Exception:
+ logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme)
+ themeInstance().clearTheme(saved_theme)
+ return
+ themeInstance().clearTheme(saved_theme)
+
def initializeApp(
) -> bool:
"""
Initialize the application components
Order:
- LogManager -> ConfigManager -> WebDriverManager
+ LogManager -> ConfigManager -> WebDriverManager -> Appearance
"""
if not _initializeLogManager():
@@ -79,4 +107,5 @@ def initializeApp(
return False
if not _initializeWebDriverManager():
return False
+ _initializeAppearance()
return True
diff --git a/src/gui/ALAutoScriptEditDialog.py b/src/gui/ALAutoScriptEditDialog.py
index 750686b..2a32510 100644
--- a/src/gui/ALAutoScriptEditDialog.py
+++ b/src/gui/ALAutoScriptEditDialog.py
@@ -9,6 +9,8 @@ See the LICENSE file for details.
"""
from copy import deepcopy
+import qtawesome as qta
+
from PySide6.QtCore import (
QDate,
QSize,
@@ -20,7 +22,6 @@ from PySide6.QtCore import (
from PySide6.QtGui import (
QColor,
QFont,
- QIcon,
QSyntaxHighlighter,
QTextCharFormat,
)
@@ -210,23 +211,27 @@ class ALAutoScriptEditDialog(QDialog):
Layout.setSpacing(3)
Layout.setContentsMargins(3, 3, 3, 3)
ToolbarLayout = QHBoxLayout()
- self.ZoomInBtn = QPushButton("+")
+ self.ZoomInBtn = QPushButton("")
+ self.ZoomInBtn.setIcon(qta.icon("fa6s.plus", color=self._iconColor()))
+ self.ZoomInBtn.setIconSize(QSize(14, 14))
self.ZoomInBtn.setFixedSize(25, 25)
- self.ZoomOutBtn = QPushButton("-")
+ self.ZoomOutBtn = QPushButton("")
+ self.ZoomOutBtn.setIcon(qta.icon("fa6s.minus", color=self._iconColor()))
+ self.ZoomOutBtn.setIconSize(QSize(14, 14))
self.ZoomOutBtn.setFixedSize(25, 25)
self.ZoomResetBtn = QPushButton("")
- self.ZoomResetBtn.setIcon(QIcon(":/res/icons/Reset.svg"))
- self.ZoomResetBtn.setIconSize(QSize(20, 20))
+ self.ZoomResetBtn.setIcon(qta.icon("fa6s.rotate-left", color=self._iconColor()))
+ self.ZoomResetBtn.setIconSize(QSize(14, 14))
self.ZoomResetBtn.setFixedSize(25, 25)
self.ZoomResetBtn.setToolTip("重置缩放")
self.ZoomLabel = QLabel(f"{self._fontSize}px")
self.ZoomLabel.setFixedHeight(25)
self.OrchBtn = QPushButton("编排")
- self.OrchBtn.setFixedHeight(25)
+ self.OrchBtn.setFixedSize(80, 25)
self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
ToolbarLayout.addWidget(self.OrchBtn)
self.DebugBtn = QPushButton("▶ 调试运行")
- self.DebugBtn.setFixedHeight(25)
+ self.DebugBtn.setFixedSize(80, 25)
self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
ToolbarLayout.addWidget(self.DebugBtn)
Sep = QFrame()
@@ -240,8 +245,8 @@ class ALAutoScriptEditDialog(QDialog):
ToolbarLayout.addWidget(self.ZoomLabel)
ToolbarLayout.addStretch()
self.CopyBtn = QPushButton("")
- self.CopyBtn.setIcon(QIcon(":/res/icons/Copy.svg"))
- self.CopyBtn.setIconSize(QSize(20, 20))
+ self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor()))
+ self.CopyBtn.setIconSize(QSize(14, 14))
self.CopyBtn.setFixedSize(25, 25)
self.CopyBtn.setToolTip("复制脚本")
ToolbarLayout.addWidget(self.CopyBtn)
@@ -264,7 +269,9 @@ class ALAutoScriptEditDialog(QDialog):
QDialogButtonBox.StandardButton.Cancel
)
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
+ self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setFixedSize(80, 25)
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
+ self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setFixedSize(80, 25)
Layout.addWidget(self.BtnBox)
def createButtonPanel(
@@ -537,6 +544,14 @@ class ALAutoScriptEditDialog(QDialog):
else:
widget.setText(str(value))
+ def _iconColor(
+ self
+ ) -> str:
+
+ return QApplication.instance().palette().color(
+ QApplication.instance().palette().ColorRole.WindowText
+ ).name()
+
def connectSignals(
self
):
diff --git a/src/gui/ALConfigWidget.py b/src/gui/ALConfigWidget.py
index eea0bb3..e06727b 100644
--- a/src/gui/ALConfigWidget.py
+++ b/src/gui/ALConfigWidget.py
@@ -42,6 +42,7 @@ from gui.ALUserTreeWidget import (
ALUserTreeWidget
)
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
+from gui.ALWidgetMixin import CenterOnParentMixin
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from interfaces.ConfigProvider import (
CfgKey,
@@ -52,7 +53,7 @@ from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
-class ALConfigWidget(QWidget, Ui_ALConfigWidget):
+class ALConfigWidget(CenterOnParentMixin, QWidget, Ui_ALConfigWidget):
configWidgetIsClosed = Signal()
@@ -110,29 +111,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
- def showEvent(
- self,
- event
- ):
-
- result = super().showEvent(event)
-
- screen_rect = self.screen().geometry()
- target_pos = self.parent().geometry().center()
- target_pos.setX(target_pos.x() - self.width()//2)
- target_pos.setY(target_pos.y() - self.height()//2)
- if target_pos.x() < 0:
- target_pos.setX(0)
- if target_pos.x() + self.width() > screen_rect.width():
- target_pos.setX(screen_rect.width() - self.width())
- if target_pos.y() < 0:
- target_pos.setY(0)
- if target_pos.y() + self.height() > screen_rect.height():
- target_pos.setY(screen_rect.height() - self.height())
- self.move(target_pos)
-
- return result
-
def closeEvent(
self,
event: QCloseEvent
diff --git a/src/gui/ALMainWindow.py b/src/gui/ALMainWindow.py
index 82a944f..5d805c6 100644
--- a/src/gui/ALMainWindow.py
+++ b/src/gui/ALMainWindow.py
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from base.MsgBase import MsgBase
from gui.ALAboutDialog import ALAboutDialog
from gui.ALConfigWidget import ALConfigWidget
+from gui.ALSettingsWidget import ALSettingsWidget
from gui.ALMainWorkers import (
AutoLibWorker,
TimerTaskWorker
@@ -60,6 +61,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None
+ self.__alSettingsWidget = None
self.__auto_lib_thread = None
self.__current_timer_task_thread = None
self.__is_running_timer_task = False
@@ -81,6 +83,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
self.ManualAction.triggered.connect(self.onManualActionTriggered)
self.AboutAction.triggered.connect(self.onAboutActionTriggered)
+ self.SettingsAction.triggered.connect(self.onSettingsActionTriggered)
# initialize timer task widget, but not show it
try:
@@ -125,7 +128,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
return
self.TrayIcon = QSystemTrayIcon(self.Icon, self)
self.TrayIcon.setToolTip("AutoLibrary")
-
self.TrayMenu = QMenu()
self.TrayMenu.addAction("显示主窗口", self.showNormal)
self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked)
@@ -190,6 +192,9 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if self.__alConfigWidget:
self.__alConfigWidget.close()
# the config widget is already deleted in the 'self.onConfigWidgetClosed'
+ if self.__alSettingsWidget:
+ self.__alSettingsWidget.close()
+ # the settings widget is already deleted in the 'self.onSettingsWidgetClosed'
self._showLog("主窗口关闭")
QMainWindow.closeEvent(self, event)
@@ -302,6 +307,31 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新")
+ @Slot()
+ def onSettingsWidgetClosed(
+ self
+ ):
+
+ if self.__alSettingsWidget:
+ self.__alSettingsWidget.settingsWidgetIsClosed.disconnect(self.onSettingsWidgetClosed)
+ self.__alSettingsWidget.deleteLater()
+ self.__alSettingsWidget = None
+ self.SettingsAction.setEnabled(True)
+
+ @Slot()
+ def onSettingsActionTriggered(
+ self
+ ):
+
+ if self.__alSettingsWidget is None:
+ self.__alSettingsWidget = ALSettingsWidget(self)
+ self.__alSettingsWidget.settingsWidgetIsClosed.connect(self.onSettingsWidgetClosed)
+ self.__alSettingsWidget.show()
+ self.__alSettingsWidget.raise_()
+ self.__alSettingsWidget.activateWindow()
+ self.SettingsAction.setEnabled(False)
+ self._showLog("打开全局设置窗口")
+
@Slot(dict)
def onTimerTaskIsReady(
self,
diff --git a/src/gui/ALSeatMapSelectDialog.py b/src/gui/ALSeatMapSelectDialog.py
index 0602dc5..67c812b 100644
--- a/src/gui/ALSeatMapSelectDialog.py
+++ b/src/gui/ALSeatMapSelectDialog.py
@@ -24,9 +24,9 @@ from PySide6.QtWidgets import (
)
from gui.ALSeatMapView import ALSeatMapView
+from gui.ALWidgetMixin import CenterOnParentMixin
-
-class ALSeatMapSelectDialog(QDialog):
+class ALSeatMapSelectDialog(CenterOnParentMixin, QDialog):
seatMapSelectDialogIsClosed = Signal(list)
@@ -96,29 +96,6 @@ class ALSeatMapSelectDialog(QDialog):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
- def showEvent(
- self,
- event
- ):
-
- result = super().showEvent(event)
-
- screen_rect = self.screen().geometry()
- target_pos = self.parent().geometry().center()
- target_pos.setX(target_pos.x() - self.width()//2)
- target_pos.setY(target_pos.y() - self.height()//2)
- if target_pos.x() < 0:
- target_pos.setX(0)
- if target_pos.x() + self.width() > screen_rect.width():
- target_pos.setX(screen_rect.width() - self.width())
- if target_pos.y() < 0:
- target_pos.setY(0)
- if target_pos.y() + self.height() > screen_rect.height():
- target_pos.setY(screen_rect.height() - self.height())
- self.move(target_pos)
-
- return result
-
def closeEvent(
self,
event: QCloseEvent
diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py
new file mode 100644
index 0000000..7dab1f1
--- /dev/null
+++ b/src/gui/ALSettingsWidget.py
@@ -0,0 +1,433 @@
+# -*- coding: utf-8 -*-
+"""
+Copyright (c) 2026 KenanZhu.
+All rights reserved.
+
+This software is provided "as is", without any warranty of any kind.
+You may use, modify, and distribute this file under the terms of the MIT License.
+See the LICENSE file for details.
+"""
+import os
+import sys
+
+import qtawesome as qta
+
+from PySide6.QtCore import (
+ QProcess,
+ Qt,
+ Signal,
+ Slot
+)
+from PySide6.QtGui import (
+ QCloseEvent
+)
+from PySide6.QtWidgets import (
+ QApplication,
+ QFileDialog,
+ QMessageBox,
+ QStyleFactory,
+ QWidget
+)
+
+import managers.config.ConfigManager as ConfigManager
+from managers.log.LogManager import instance as logInstance
+from managers.theme.ThemeManager import(
+ getActiveStyle,
+ setActiveStyle,
+ instance as themeInstance
+)
+
+from gui.ALWidgetMixin import CenterOnParentMixin
+from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
+from interfaces.ConfigProvider import (
+ CfgKey,
+ ConfigProvider
+)
+
+
+def _applyCustomTheme(
+ name: str,
+ fallback_theme: str = "system"
+) -> bool:
+
+ if not name:
+ themeInstance().clearTheme(fallback_theme)
+ return True
+ try:
+ themeInstance().applyTheme(name)
+ return True
+ except Exception as e:
+ logInstance().getLogger("ALSettingsWidget").warning(
+ f"无法应用自定义主题 '{name}',回退到 {fallback_theme} 外观: {e}"
+ )
+ themeInstance().clearTheme(fallback_theme)
+ return False
+
+def _themeToReadable(
+ need_theme: str
+) -> str:
+
+ if need_theme == "dark":
+ return "深色"
+ elif need_theme == "light":
+ return "浅色"
+ elif need_theme == "both":
+ return "所有"
+ else:
+ return "未知"
+
+def _restartApp(
+):
+
+ QApplication.instance().quit()
+ QProcess.startDetached(sys.executable, sys.argv)
+
+
+class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget):
+
+ settingsWidgetIsClosed = Signal()
+
+ def __init__(
+ self,
+ parent=None
+ ):
+ super().__init__(parent)
+ self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
+ self.__original_theme: str = ""
+ self.__original_custom_theme: str = ""
+ self.__original_style: str = ""
+
+ self.setupUi(self)
+ self.modifyUi()
+ self.connectSignals()
+ self.loadSettings()
+
+ def closeEvent(
+ self,
+ event: QCloseEvent
+ ):
+
+ self.settingsWidgetIsClosed.emit()
+ super().closeEvent(event)
+
+ def modifyUi(
+ self
+ ):
+
+ self.setWindowFlags(Qt.WindowType.Window)
+ self.setNavigationIcons()
+ color = QApplication.instance().palette().color(
+ QApplication.instance().palette().ColorRole.WindowText
+ ).name()
+ self.ImportCustomThemeButton.setIcon(qta.icon("fa6s.plus", color=color))
+ self.ImportCustomThemeButton.setText("")
+ self.RemoveCustomThemeButton.setIcon(qta.icon("fa6s.minus", color=color))
+ self.RemoveCustomThemeButton.setText("")
+ self.CustomThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
+ self.CustomThemeInfoLabel.setStyleSheet(
+ "border: 1px solid palette(mid);"\
+ "border-radius: 2px;"\
+ "padding: 5px;"
+ )
+ self.NavigationList.setCurrentRow(0)
+ self.populateStyles()
+ self.populateCustomThemes()
+
+ def setNavigationIcons(
+ self
+ ):
+
+ app : QApplication | None = QApplication.instance()
+ color = app.palette().color(app.palette().ColorRole.WindowText).name()
+ item = self.NavigationList.item(0)
+ if item:
+ item.setIcon(qta.icon("fa6s.palette", color=color))
+
+ def populateStyles(
+ self
+ ):
+
+ self.StyleComboBox.clear()
+ self.StyleComboBox.addItems(QStyleFactory.keys())
+
+ def populateCustomThemes(
+ self
+ ):
+
+ self.CustomThemeComboBox.blockSignals(True)
+ self.CustomThemeComboBox.clear()
+ self.CustomThemeComboBox.addItem("默认", "")
+ self.__theme_cache = {}
+ themes = themeInstance().listThemes()
+ for t in themes:
+ name = t.get("name", "")
+ file = t.get("file", name)
+ author = t.get("author", "")
+ if name:
+ self.__theme_cache[file] = t
+ self.CustomThemeComboBox.addItem(name, file)
+ self.CustomThemeComboBox.blockSignals(False)
+
+ def connectSignals(
+ self
+ ):
+
+ self.ImportCustomThemeButton.clicked.connect(self.onImportCustomThemeButtonClicked)
+ self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked)
+ self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged)
+ self.ResetCustomThemeButton.clicked.connect(self.onResetCustomThemeButtonClicked)
+ self.CancelButton.clicked.connect(self.onCancelButtonClicked)
+ self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
+ self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
+
+ def loadSettings(
+ self
+ ):
+
+ theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
+ style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
+ custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
+ self.__original_theme = theme
+ self.__original_custom_theme = custom_theme
+ self.__original_style = getActiveStyle()
+ if theme == "light":
+ self.LightThemeRadio.setChecked(True)
+ elif theme == "dark":
+ self.DarkThemeRadio.setChecked(True)
+ else:
+ self.SystemThemeRadio.setChecked(True)
+ index = self.StyleComboBox.findText(style)
+ if index < 0:
+ index = 0
+ self.StyleComboBox.setCurrentIndex(index)
+ if custom_theme:
+ idx = self.CustomThemeComboBox.findData(custom_theme)
+ if idx >= 0:
+ self.CustomThemeComboBox.setCurrentIndex(idx)
+ self.updateCustomThemeInfo()
+ self.updateCustomThemeStatus()
+
+ def updateCustomThemeInfo(
+ self
+ ):
+
+ file = self.CustomThemeComboBox.currentData()
+ if not file:
+ self.CustomThemeInfoLabel.setText("")
+ return
+ t = self.__theme_cache.get(file)
+ if t:
+ name = t.get("name", "未知")
+ author = t.get("author", "未知作者")
+ need_theme = t.get("need_theme", "both")
+ brief = t.get("brief", "没有相关简介")
+ self.CustomThemeInfoLabel.setText(
+ f"{name} - 适用于 {_themeToReadable(need_theme)} 主题
"
+ f"作者:{author}
"
+ f"{brief}"
+ )
+ else:
+ self.CustomThemeInfoLabel.setText("")
+
+ def updateCustomThemeStatus(
+ self
+ ):
+
+ file = self.CustomThemeComboBox.currentData()
+ t = self.__theme_cache.get(file) if file else None
+ name = t.get("name", "") if t else ""
+ if name:
+ self.CustomThemeStatusLabel.setText(f"当前使用 {name} 主题。")
+ else:
+ self.CustomThemeStatusLabel.setText("当前使用 默认 主题。")
+
+ def syncRadioFromNeedTheme(
+ self,
+ name: str
+ ):
+
+ t = self.__theme_cache.get(name)
+ if t:
+ need_theme = t.get("need_theme", "both")
+ if need_theme == "light":
+ self.LightThemeRadio.setChecked(True)
+ elif need_theme == "dark":
+ self.DarkThemeRadio.setChecked(True)
+
+ def collectSettings(
+ self
+ ):
+
+ if self.LightThemeRadio.isChecked():
+ theme = "light"
+ elif self.DarkThemeRadio.isChecked():
+ theme = "dark"
+ else:
+ theme = "system"
+ style = self.StyleComboBox.currentText()
+ custom_theme = self.CustomThemeComboBox.currentData() or ""
+ if not custom_theme:
+ custom_theme = ""
+ return theme, style, custom_theme
+
+ def saveAndApply(
+ self
+ ):
+
+ theme, style, custom_theme = self.collectSettings()
+ self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style)
+ self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme)
+ setActiveStyle(style)
+ if not _applyCustomTheme(custom_theme, theme):
+ self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
+ self.syncRadioFromNeedTheme(custom_theme)
+ # Re-read theme after syncRadioFromNeedTheme — the radio may have
+ # changed to match the custom theme's need_theme
+ theme, _, _ = self.collectSettings()
+ self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
+ self.setNavigationIcons()
+ self.updateCustomThemeStatus()
+ self.updateCustomThemeInfo()
+ self.__original_theme = theme
+ self.__original_custom_theme = custom_theme if custom_theme else ""
+ self.__original_style = getActiveStyle()
+
+ def maybeRestart(
+ self
+ ) -> bool:
+
+ reply = QMessageBox.question(
+ self,
+ "提示 - AutoLibrary",
+ "界面风格已修改,需要重启程序才能生效。是否立即重启?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.Yes
+ )
+ if reply == QMessageBox.Yes:
+ _restartApp()
+ return True
+ return False
+
+ @Slot()
+ def onRemoveCustomThemeButtonClicked(
+ self
+ ):
+
+ file = self.CustomThemeComboBox.currentData()
+ if not file:
+ QMessageBox.information(
+ self,
+ "提示 - AutoLibrary",
+ "请先选择一个主题。"
+ )
+ return
+ t = self.__theme_cache.get(file)
+ name = t.get("name", file) if t else file
+ reply = QMessageBox.question(
+ self,
+ "删除主题 - AutoLibrary",
+ f"确定要删除主题 \"{name}\" 吗?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if reply != QMessageBox.Yes:
+ return
+ try:
+ themeInstance().removeTheme(file)
+ self.populateCustomThemes()
+ self.CustomThemeComboBox.setCurrentIndex(0)
+ self.updateCustomThemeStatus()
+ self.updateCustomThemeInfo()
+ except Exception as e:
+ QMessageBox.warning(
+ self,
+ "删除失败 - AutoLibrary",
+ f"无法删除主题:{e}"
+ )
+
+ @Slot()
+ def onImportCustomThemeButtonClicked(
+ self
+ ):
+
+ file_path, _ = QFileDialog.getOpenFileName(
+ self,
+ "导入主题 - AutoLibrary",
+ "",
+ "主题文件 (*.altheme *.qss);;所有文件 (*)"
+ )
+ if not file_path:
+ return
+ try:
+ file_id = themeInstance().importTheme(file_path)
+ self.populateCustomThemes()
+ idx = self.CustomThemeComboBox.findData(file_id)
+ if idx >= 0:
+ self.CustomThemeComboBox.setCurrentIndex(idx)
+ self.updateCustomThemeStatus()
+ self.updateCustomThemeInfo()
+ except Exception as e:
+ QMessageBox.warning(
+ self,
+ "导入失败 - AutoLibrary",
+ f"无法导入主题文件:{e}"
+ )
+
+ @Slot()
+ def onCustomThemeComboBoxChanged(
+ self,
+ index: int
+ ):
+
+ self.updateCustomThemeInfo()
+ # no status update, because custom theme is not applied yet.
+
+ @Slot()
+ def onResetCustomThemeButtonClicked(
+ self
+ ):
+
+ self.CustomThemeComboBox.blockSignals(True)
+ if self.__original_custom_theme:
+ idx = self.CustomThemeComboBox.findData(self.__original_custom_theme)
+ if idx >= 0:
+ self.CustomThemeComboBox.setCurrentIndex(idx)
+ else:
+ self.CustomThemeComboBox.setCurrentIndex(0)
+ else:
+ self.CustomThemeComboBox.setCurrentIndex(0)
+ self.CustomThemeComboBox.blockSignals(False)
+ if self.__original_theme == "light":
+ self.LightThemeRadio.setChecked(True)
+ elif self.__original_theme == "dark":
+ self.DarkThemeRadio.setChecked(True)
+ else:
+ self.SystemThemeRadio.setChecked(True)
+ self.updateCustomThemeInfo()
+ self.updateCustomThemeStatus()
+
+ @Slot()
+ def onCancelButtonClicked(
+ self
+ ):
+
+ self.close()
+
+ @Slot()
+ def onApplyButtonClicked(
+ self
+ ):
+
+ _, style, _ = self.collectSettings()
+ style_changed = self.__original_style != style
+ self.saveAndApply()
+ if style_changed:
+ self.maybeRestart()
+
+ @Slot()
+ def onConfirmButtonClicked(
+ self
+ ):
+
+ self.onApplyButtonClicked() # virtually call apply button clicked
+ self.close()
diff --git a/src/gui/ALTimerTaskManageWidget.py b/src/gui/ALTimerTaskManageWidget.py
index c6e191f..8b9ac30 100644
--- a/src/gui/ALTimerTaskManageWidget.py
+++ b/src/gui/ALTimerTaskManageWidget.py
@@ -43,6 +43,7 @@ from gui.ALTimerTaskAddDialog import (
ALTimerTaskStatus
)
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
+from gui.ALWidgetMixin import CenterOnParentMixin
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from interfaces.ConfigProvider import (
CfgKey,
@@ -189,7 +190,7 @@ class ALTimerTaskItemWidget(QWidget):
Menu.exec(self.mapToGlobal(pos))
-class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
+class ALTimerTaskManageWidget(CenterOnParentMixin, QWidget, Ui_ALTimerTaskManageWidget):
class SortPolicy(Enum):
@@ -299,29 +300,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
)
return False
- def showEvent(
- self,
- event
- ):
-
- result = super().showEvent(event)
-
- screen_rect = self.screen().geometry()
- target_pos = self.parent().geometry().center()
- target_pos.setX(target_pos.x() - self.width()//2)
- target_pos.setY(target_pos.y() - self.height()//2)
- if target_pos.x() < 0:
- target_pos.setX(0)
- if target_pos.x() + self.width() > screen_rect.width():
- target_pos.setX(screen_rect.width() - self.width())
- if target_pos.y() < 0:
- target_pos.setY(0)
- if target_pos.y() + self.height() > screen_rect.height():
- target_pos.setY(screen_rect.height() - self.height())
- self.move(target_pos)
-
- return result
-
def closeEvent(
self,
event: QCloseEvent
diff --git a/src/gui/ALWebDriverDownloadDialog.py b/src/gui/ALWebDriverDownloadDialog.py
index 42ac710..aaf619e 100644
--- a/src/gui/ALWebDriverDownloadDialog.py
+++ b/src/gui/ALWebDriverDownloadDialog.py
@@ -38,6 +38,7 @@ from managers.driver.WebDriverManager import (
WebDriverStatus
)
from gui.ALStatusLabel import ALStatusLabel
+from gui.ALWidgetMixin import CenterOnParentMixin
class DownloadWorker(QThread):
@@ -123,7 +124,7 @@ class DownloadWorker(QThread):
self.wait()
-class ALWebDriverDownloadDialog(QDialog):
+class ALWebDriverDownloadDialog(CenterOnParentMixin, QDialog):
def __init__(
self,
@@ -152,28 +153,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.initializeDriverManager()
self.refreshDriverList()
- def showEvent(
- self,
- event
- ):
-
- result = super().showEvent(event)
- if self.parent():
- screen_rect = self.screen().geometry()
- target_pos = self.parent().geometry().center()
- target_pos.setX(target_pos.x() - self.width()//2)
- target_pos.setY(target_pos.y() - self.height()//2)
- if target_pos.x() < 0:
- target_pos.setX(0)
- if target_pos.x() + self.width() > screen_rect.width():
- target_pos.setX(screen_rect.width() - self.width())
- if target_pos.y() < 0:
- target_pos.setY(0)
- if target_pos.y() + self.height() > screen_rect.height():
- target_pos.setY(screen_rect.height() - self.height())
- self.move(target_pos)
- return result
-
def setupUi(
self
):
diff --git a/src/gui/ALWidgetMixin.py b/src/gui/ALWidgetMixin.py
new file mode 100644
index 0000000..0e95ebf
--- /dev/null
+++ b/src/gui/ALWidgetMixin.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+"""
+Copyright (c) 2026 KenanZhu.
+All rights reserved.
+
+This software is provided "as is", without any warranty of any kind.
+You may use, modify, and distribute this file under the terms of the MIT License.
+See the LICENSE file for details.
+"""
+from PySide6.QtGui import QShowEvent
+
+
+class CenterOnParentMixin:
+ """
+ Mixin that centres the widget relative to its parent on first show,
+ clamping the position to the screen bounds.
+
+ Usage::
+
+ class MyWidget(CenterOnParentMixin, QWidget, Ui_MyWidget):
+ pass
+
+ class MyDialog(CenterOnParentMixin, QDialog):
+ pass
+
+ The mixin must appear **before** QWidget / QDialog in the base list
+ so that ``super().showEvent(event)`` resolves up the MRO correctly.
+ """
+
+ def showEvent(
+ self,
+ event: QShowEvent
+ ):
+
+ super().showEvent(event)
+ if self.parent():
+ screen_rect = self.screen().geometry()
+ target_pos = self.parent().geometry().center()
+ target_pos.setX(target_pos.x() - self.width() // 2)
+ target_pos.setY(target_pos.y() - self.height() // 2)
+ if target_pos.x() < 0:
+ target_pos.setX(0)
+ if target_pos.x() + self.width() > screen_rect.width():
+ target_pos.setX(screen_rect.width() - self.width())
+ if target_pos.y() < 0:
+ target_pos.setY(0)
+ if target_pos.y() + self.height() > screen_rect.height():
+ target_pos.setY(screen_rect.height() - self.height())
+ self.move(target_pos)
diff --git a/src/gui/resources/themes/BlueForest.qss b/src/gui/resources/themes/BlueForest.qss
new file mode 100644
index 0000000..0e6db26
--- /dev/null
+++ b/src/gui/resources/themes/BlueForest.qss
@@ -0,0 +1,539 @@
+/*
+ * Copyright (c) 2026 KenanZhu.
+ * All rights reserved.
+ *
+ * This software is provided "as is", without any warranty of any kind.
+ * You may use, modify, and distribute this file under the terms of the MIT License.
+ * See the LICENSE file for details.
+ *
+ *
+ * AutoLibrary Official Theme : BlueForest
+ */
+
+/* ---- Global ---- */
+QMainWindow::separator {
+ background-color: #1c2840;
+ width: 1px;
+ height: 1px;
+}
+
+/* ---- Menu Bar ---- */
+QMenuBar {
+ background-color: #0f1628;
+ border-bottom: 1px solid #1c2840;
+ padding: 2px 5px;
+ color: #d0daf0;
+}
+QMenuBar::item {
+ padding: 2px 10px;
+ border-radius: 4px;
+}
+QMenuBar::item:selected {
+ background-color: #1c2840;
+}
+QMenu {
+ background-color: #162038;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ padding: 4px;
+ border-radius: 6px;
+}
+QMenu::item {
+ padding: 5px 15px 5px 10px;
+ border-radius: 4px;
+}
+QMenu::item:selected {
+ background-color: #2dd4bf;
+ color: #0f1119;
+}
+QMenu::separator {
+ height: 1px;
+ background-color: #253250;
+ margin: 4px 8px;
+}
+
+/* ---- Button ---- */
+QPushButton {
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+ color: #d0daf0;
+ padding: 4px 12px;
+ background-color: #1c2840;
+}
+QPushButton:hover {
+ background-color: #243458;
+ border-color: #334478;
+}
+QPushButton:pressed {
+ background-color: #162038;
+ border-color: #2dd4bf;
+}
+QPushButton:disabled {
+ background-color: #162038;
+ color: #5568a0;
+ border-color: #1c2840;
+}
+QPushButton[default="true"] {
+ background-color: #2dd4bf;
+ color: #0f1119;
+ border-color: #2dd4bf;
+}
+QPushButton[default="true"]:hover {
+ background-color: #3de0cc;
+}
+
+/* ---- Input ---- */
+QLineEdit,
+QPlainTextEdit,
+QTextEdit,
+QSpinBox,
+QDoubleSpinBox,
+QDateEdit,
+QTimeEdit {
+ background-color: #0a1020;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+ padding: 4px 8px;
+ color: #d0daf0;
+ selection-background-color: #2dd4bf;
+ selection-color: #0f1119;
+}
+QLineEdit:focus,
+QPlainTextEdit:focus,
+QTextEdit:focus,
+QSpinBox:focus,
+QDoubleSpinBox:focus,
+QDateEdit:focus,
+QTimeEdit:focus {
+ border-color: #2dd4bf;
+}
+QPlainTextEdit,
+QTextEdit {
+ background-color: #0a1020;
+}
+QLineEdit:disabled,
+QPlainTextEdit:disabled,
+QTextEdit:disabled,
+QSpinBox:disabled,
+QDoubleSpinBox:disabled,
+QDateEdit:disabled,
+QTimeEdit:disabled {
+ background-color: #162038;
+ color: #5568a0;
+ border-color: #1c2840;
+}
+
+/* ---- Spin Button Arrows ---- */
+QSpinBox::up-button,
+QDoubleSpinBox::up-button,
+QDateEdit::up-button,
+QTimeEdit::up-button {
+ subcontrol-origin: border;
+ subcontrol-position: top right;
+ width: 10px;
+ border-left: 1px solid #253250;
+ border-bottom: 1px solid #253250;
+ border-top-right-radius: 4px;
+}
+QSpinBox::up-button:hover,
+QDoubleSpinBox::up-button:hover,
+QDateEdit::up-button:hover,
+QTimeEdit::up-button:hover {
+ background-color: #1c2840;
+}
+QSpinBox::up-arrow,
+QDoubleSpinBox::up-arrow,
+QDateEdit::up-arrow,
+QTimeEdit::up-arrow {
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-bottom: 5px solid #7888b8;
+ margin-top: 2px;
+}
+QSpinBox::down-button,
+QDoubleSpinBox::down-button,
+QDateEdit::down-button,
+QTimeEdit::down-button {
+ width: 10px;
+ subcontrol-origin: border;
+ subcontrol-position: bottom right;
+ border-left: 1px solid #253250;
+ border-bottom-right-radius: 4px;
+}
+QSpinBox::down-button:hover,
+QDoubleSpinBox::down-button:hover,
+QDateEdit::down-button:hover,
+QTimeEdit::down-button:hover {
+ background-color: #1c2840;
+}
+QSpinBox::down-arrow,
+QDoubleSpinBox::down-arrow,
+QDateEdit::down-arrow,
+QTimeEdit::down-arrow {
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 5px solid #7888b8;
+ margin-bottom: 2px;
+}
+QSpinBox::up-button:disabled,
+QDoubleSpinBox::up-button:disabled,
+QDateEdit::up-button:disabled,
+QTimeEdit::up-button:disabled,
+QSpinBox::down-button:disabled,
+QDoubleSpinBox::down-button:disabled,
+QDateEdit::down-button:disabled,
+QTimeEdit::down-button:disabled {
+ background-color: #162038;
+}
+QSpinBox::up-arrow:disabled,
+QDoubleSpinBox::up-arrow:disabled,
+QDateEdit::up-arrow:disabled,
+QTimeEdit::up-arrow:disabled {
+ border-bottom-color: #5568a0;
+}
+QSpinBox::down-arrow:disabled,
+QDoubleSpinBox::down-arrow:disabled,
+QDateEdit::down-arrow:disabled,
+QTimeEdit::down-arrow:disabled {
+ border-top-color: #5568a0;
+}
+
+/* ---- Combo Box ---- */
+QComboBox {
+ background-color: #1c2840;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+ padding: 4px 10px;
+ color: #d0daf0;
+}
+QComboBox:hover {
+ border-color: #334478;
+}
+QComboBox:focus {
+ border-color: #2dd4bf;
+}
+QComboBox::drop-down {
+ subcontrol-origin: padding;
+ subcontrol-position: top right;
+ width: 24px;
+ border-left: 1px solid #253250;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+QComboBox::down-arrow {
+ image: none;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 6px solid #7888b8;
+ margin-right: 6px;
+}
+QComboBox QAbstractItemView {
+ background-color: #162038;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 4px;
+ selection-background-color: #2dd4bf;
+ selection-color: #0f1119;
+ outline: none;
+}
+QComboBox:disabled {
+ background-color: #162038;
+ color: #5568a0;
+ border-color: #1c2840;
+}
+
+/* ---- Check Box / Radio Button ---- */
+QCheckBox,
+QRadioButton {
+ spacing: 5px;
+ color: #d0daf0;
+}
+QCheckBox::indicator,
+QRadioButton::indicator {
+ border-style: solid;
+ border-color: #334478;
+ border-width: 2px;
+ background-color: #0a1020;
+}
+QCheckBox::indicator {
+ border-radius: 3px;
+}
+QRadioButton::indicator {
+ border-radius: 7px;
+}
+QCheckBox::indicator:hover,
+QRadioButton::indicator:hover {
+ border-color: #2dd4bf;
+}
+QCheckBox::indicator:checked {
+ background-color: #2dd4bf;
+ border-color: #2dd4bf;
+}
+QRadioButton::indicator:checked {
+ background-color: #2dd4bf;
+ border-color: #2dd4bf;
+}
+QCheckBox::indicator:disabled,
+QRadioButton::indicator:disabled {
+ border-color: #253250;
+ background-color: #162038;
+}
+QCheckBox::indicator:checked:hover,
+QRadioButton::indicator:checked:hover {
+ border-color: #a0f0e8;
+}
+
+/* Tree / List / Table Widget CheckBox Indicator */
+QTreeWidget::indicator,
+QListWidget::indicator,
+QTableWidget::indicator {
+ border: 2px solid #5568a0;
+ border-radius: 3px;
+ background-color: #162038;
+}
+QTreeWidget::indicator:hover,
+QListWidget::indicator:hover,
+QTableWidget::indicator:hover {
+ border-color: #a0f0e8;
+}
+QTreeWidget::indicator:checked,
+QListWidget::indicator:checked,
+QTableWidget::indicator:checked {
+ background-color: #2dd4bf;
+ border-color: #2dd4bf;
+}
+QTreeWidget::indicator:checked:hover,
+QListWidget::indicator:checked:hover,
+QTableWidget::indicator:checked:hover {
+ border-color: #a0f0e8;
+}
+QTreeWidget::indicator:disabled,
+QListWidget::indicator:disabled,
+QTableWidget::indicator:disabled {
+ background-color: #1c2840;
+ border-color: #334478;
+}
+QTreeWidget::indicator:indeterminate,
+QListWidget::indicator:indeterminate,
+QTableWidget::indicator:indeterminate {
+ background-color: #2dd4bf;
+ border-color: #a0f0e8;
+}
+
+/* ---- Group Box ---- */
+QGroupBox {
+ margin-top: 5px;
+ padding-top: 15px;
+ color: #b4c2f5;
+ font-weight: bold;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+}
+
+/* ---- Tab ---- */
+QTabWidget::pane {
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+ background-color: #0f1a2e;
+ top: -1px;
+}
+QTabBar::tab {
+ background-color: #162038;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-bottom: none;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ padding: 6px 16px;
+ margin-right: 2px;
+ color: #7888b8;
+}
+QTabBar::tab:selected {
+ background-color: #0f1a2e;
+ color: #2dd4bf;
+ border-bottom: 2px solid #2dd4bf;
+}
+
+/* ---- List / Tree ---- */
+QListWidget,
+QTreeWidget,
+QTableWidget {
+ background-color: #0a1020;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+ outline: none;
+ color: #d0daf0;
+ alternate-background-color: #101c30;
+}
+QListWidget::item,
+QTreeWidget::item,
+QTableWidget::item {
+ padding: 5px 5px;
+}
+QHeaderView::section {
+ background-color: #0f1628;
+ border-right: 1px solid #253250;
+ border-bottom: 1px solid #253250;
+ padding: 5px 10px;
+ color: #8b9ad0;
+ font-weight: bold;
+}
+
+/* ---- Scroll Bar ---- */
+QScrollBar:vertical {
+ background-color: #0f1a2e;
+ width: 10px;
+ border-radius: 5px;
+}
+QScrollBar::handle:vertical {
+ background-color: #334478;
+ min-height: 30px;
+ border-radius: 5px;
+}
+QScrollBar::handle:vertical:hover {
+ background-color: #5568a0;
+}
+QScrollBar::add-line:vertical,
+QScrollBar::sub-line:vertical {
+ height: 0;
+}
+QScrollBar:horizontal {
+ background-color: #0f1a2e;
+ height: 10px;
+ border-radius: 5px;
+}
+QScrollBar::handle:horizontal {
+ background-color: #334478;
+ min-width: 30px;
+ border-radius: 5px;
+}
+QScrollBar::handle:horizontal:hover {
+ background-color: #5568a0;
+}
+QScrollBar::add-line:horizontal,
+QScrollBar::sub-line:horizontal {
+ width: 0;
+}
+
+/* ---- Progress Bar ---- */
+QProgressBar {
+ background-color: #0a1020;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 5px;
+ height: 10px;
+ text-align: center;
+ color: #d0daf0;
+}
+QProgressBar::chunk {
+ background-color: #2dd4bf;
+ border-radius: 4px;
+}
+
+/* ---- Slider ---- */
+QSlider::groove:horizontal {
+ background-color: #1c2840;
+ height: 6px;
+ border-radius: 3px;
+}
+QSlider::handle:horizontal {
+ background-color: #2dd4bf;
+ width: 16px;
+ height: 16px;
+ margin: -5px 0;
+ border-radius: 8px;
+}
+QSlider::sub-page:horizontal {
+ background-color: #2dd4bf;
+ border-radius: 3px;
+}
+QSlider::handle:horizontal:disabled {
+ background-color: #5568a0;
+}
+QSlider::sub-page:horizontal:disabled {
+ background-color: #5568a0;
+}
+
+/* ---- Tool Tip ---- */
+QToolTip {
+ background-color: #1c2840;
+ border-style: solid;
+ border-color: #2dd4bf;
+ border-width: 1px;
+ border-radius: 4px;
+ padding: 4px 8px;
+ color: #d0daf0;
+}
+
+/* ---- Status Bar ---- */
+QStatusBar {
+ background-color: #0f1628;
+ border-top: 1px solid #1c2840;
+ color: #7888b8;
+}
+
+/* ---- Splitter ---- */
+QSplitter::handle {
+ background-color: #253250;
+ margin: 1px;
+}
+QSplitter::handle:horizontal {
+ width: 2px;
+}
+QSplitter::handle:vertical {
+ height: 2px;
+}
+
+/* ---- Dialog ---- */
+QDialog {
+ background-color: #0f1a2e;
+}
+
+/* ---- Date / Time Editor Drop-down ---- */
+QDateEdit::drop-down,
+QTimeEdit::drop-down {
+ subcontrol-origin: padding;
+ subcontrol-position: top right;
+ width: 24px;
+ border-left: 1px solid #253250;
+}
+QCalendarWidget {
+ background-color: #162038;
+ border-style: solid;
+ border-color: #253250;
+ border-width: 1px;
+ border-radius: 6px;
+}
+QCalendarWidget QToolButton {
+ color: #d0daf0;
+ border-radius: 4px;
+ padding: 4px 8px;
+}
+QCalendarWidget QToolButton:hover {
+ background-color: #1c2840;
+}
+QCalendarWidget QMenu {
+ background-color: #162038;
+}
+
+/* ---- Frame ---- */
+QFrame[frameShape="4"], /* HLine */
+QFrame[frameShape="5"] /* VLine */ {
+ background-color: #253250;
+}
diff --git a/src/gui/resources/themes/LightLake.qss b/src/gui/resources/themes/LightLake.qss
new file mode 100644
index 0000000..2ea0239
--- /dev/null
+++ b/src/gui/resources/themes/LightLake.qss
@@ -0,0 +1,539 @@
+/*
+ * Copyright (c) 2026 KenanZhu.
+ * All rights reserved.
+ *
+ * This software is provided "as is", without any warranty of any kind.
+ * You may use, modify, and distribute this file under the terms of the MIT License.
+ * See the LICENSE file for details.
+ *
+ *
+ * AutoLibrary Official Theme : LightLake
+ */
+
+/* ---- Global ---- */
+QMainWindow::separator {
+ background-color: #c0cdda;
+ width: 1px;
+ height: 1px;
+}
+
+/* ---- Menu Bar ---- */
+QMenuBar {
+ background-color: #dce4ee;
+ border-bottom: 1px solid #c0cdda;
+ padding: 2px 5px;
+ color: #1a2740;
+}
+QMenuBar::item {
+ padding: 2px 10px;
+ border-radius: 4px;
+}
+QMenuBar::item:selected {
+ background-color: #d5dde8;
+}
+QMenu {
+ background-color: #ffffff;
+ border-style: solid;
+ border-color: #d0d8e4;
+ border-width: 1px;
+ padding: 4px;
+ border-radius: 6px;
+}
+QMenu::item {
+ padding: 5px 15px 5px 10px;
+ border-radius: 4px;
+}
+QMenu::item:selected {
+ background-color: #0ea58a;
+ color: #ffffff;
+}
+QMenu::separator {
+ height: 1px;
+ background-color: #d0d8e4;
+ margin: 4px 8px;
+}
+
+/* ---- Button ---- */
+QPushButton {
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+ color: #1a2740;
+ padding: 4px 12px;
+ background-color: #d5dde8;
+}
+QPushButton:hover {
+ background-color: #c8d4e2;
+ border-color: #90a4c4;
+}
+QPushButton:pressed {
+ background-color: #e2e8f0;
+ border-color: #0ea58a;
+}
+QPushButton:disabled {
+ background-color: #e8ecf2;
+ color: #98a8c0;
+ border-color: #d5dde8;
+}
+QPushButton[default="true"] {
+ background-color: #0ea58a;
+ color: #ffffff;
+ border-color: #0ea58a;
+}
+QPushButton[default="true"]:hover {
+ background-color: #14c7a4;
+}
+
+/* ---- Input ---- */
+QLineEdit,
+QPlainTextEdit,
+QTextEdit,
+QSpinBox,
+QDoubleSpinBox,
+QDateEdit,
+QTimeEdit {
+ background-color: #ffffff;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+ padding: 4px 8px;
+ color: #1a2740;
+ selection-background-color: #0ea58a;
+ selection-color: #ffffff;
+}
+QLineEdit:focus,
+QPlainTextEdit:focus,
+QTextEdit:focus,
+QSpinBox:focus,
+QDoubleSpinBox:focus,
+QDateEdit:focus,
+QTimeEdit:focus {
+ border-color: #0ea58a;
+}
+QPlainTextEdit,
+QTextEdit {
+ background-color: #ffffff;
+}
+QLineEdit:disabled,
+QPlainTextEdit:disabled,
+QTextEdit:disabled,
+QSpinBox:disabled,
+QDoubleSpinBox:disabled,
+QDateEdit:disabled,
+QTimeEdit:disabled {
+ background-color: #e8ecf2;
+ color: #98a8c0;
+ border-color: #d5dde8;
+}
+
+/* ---- Spin Button Arrows ---- */
+QSpinBox::up-button,
+QDoubleSpinBox::up-button,
+QDateEdit::up-button,
+QTimeEdit::up-button {
+ subcontrol-origin: border;
+ subcontrol-position: top right;
+ width: 10px;
+ border-left: 1px solid #c0cdda;
+ border-bottom: 1px solid #c0cdda;
+ border-top-right-radius: 4px;
+}
+QSpinBox::up-button:hover,
+QDoubleSpinBox::up-button:hover,
+QDateEdit::up-button:hover,
+QTimeEdit::up-button:hover {
+ background-color: #d5dde8;
+}
+QSpinBox::up-arrow,
+QDoubleSpinBox::up-arrow,
+QDateEdit::up-arrow,
+QTimeEdit::up-arrow {
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-bottom: 5px solid #6a7898;
+ margin-top: 2px;
+}
+QSpinBox::down-button,
+QDoubleSpinBox::down-button,
+QDateEdit::down-button,
+QTimeEdit::down-button {
+ width: 10px;
+ subcontrol-origin: border;
+ subcontrol-position: bottom right;
+ border-left: 1px solid #c0cdda;
+ border-bottom-right-radius: 4px;
+}
+QSpinBox::down-button:hover,
+QDoubleSpinBox::down-button:hover,
+QDateEdit::down-button:hover,
+QTimeEdit::down-button:hover {
+ background-color: #d5dde8;
+}
+QSpinBox::down-arrow,
+QDoubleSpinBox::down-arrow,
+QDateEdit::down-arrow,
+QTimeEdit::down-arrow {
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 5px solid #6a7898;
+ margin-bottom: 2px;
+}
+QSpinBox::up-button:disabled,
+QDoubleSpinBox::up-button:disabled,
+QDateEdit::up-button:disabled,
+QTimeEdit::up-button:disabled,
+QSpinBox::down-button:disabled,
+QDoubleSpinBox::down-button:disabled,
+QDateEdit::down-button:disabled,
+QTimeEdit::down-button:disabled {
+ background-color: #e8ecf2;
+}
+QSpinBox::up-arrow:disabled,
+QDoubleSpinBox::up-arrow:disabled,
+QDateEdit::up-arrow:disabled,
+QTimeEdit::up-arrow:disabled {
+ border-bottom-color: #98a8c0;
+}
+QSpinBox::down-arrow:disabled,
+QDoubleSpinBox::down-arrow:disabled,
+QDateEdit::down-arrow:disabled,
+QTimeEdit::down-arrow:disabled {
+ border-top-color: #98a8c0;
+}
+
+/* ---- Combo Box ---- */
+QComboBox {
+ background-color: #d5dde8;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+ padding: 4px 10px;
+ color: #1a2740;
+}
+QComboBox:hover {
+ border-color: #90a4c4;
+}
+QComboBox:focus {
+ border-color: #0ea58a;
+}
+QComboBox::drop-down {
+ subcontrol-origin: padding;
+ subcontrol-position: top right;
+ width: 24px;
+ border-left: 1px solid #c0cdda;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+QComboBox::down-arrow {
+ image: none;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 6px solid #6a7898;
+ margin-right: 6px;
+}
+QComboBox QAbstractItemView {
+ background-color: #ffffff;
+ border-style: solid;
+ border-color: #d0d8e4;
+ border-width: 1px;
+ border-radius: 4px;
+ selection-background-color: #0ea58a;
+ selection-color: #ffffff;
+ outline: none;
+}
+QComboBox:disabled {
+ background-color: #e8ecf2;
+ color: #98a8c0;
+ border-color: #d5dde8;
+}
+
+/* ---- Check Box / Radio Button ---- */
+QCheckBox,
+QRadioButton {
+ spacing: 5px;
+ color: #1a2740;
+}
+QCheckBox::indicator,
+QRadioButton::indicator {
+ border-style: solid;
+ border-color: #90a4c4;
+ border-width: 2px;
+ background-color: #ffffff;
+}
+QCheckBox::indicator {
+ border-radius: 3px;
+}
+QRadioButton::indicator {
+ border-radius: 7px;
+}
+QCheckBox::indicator:hover,
+QRadioButton::indicator:hover {
+ border-color: #0ea58a;
+}
+QCheckBox::indicator:checked {
+ background-color: #0ea58a;
+ border-color: #0ea58a;
+}
+QRadioButton::indicator:checked {
+ background-color: #0ea58a;
+ border-color: #0ea58a;
+}
+QCheckBox::indicator:disabled,
+QRadioButton::indicator:disabled {
+ border-color: #c0cdda;
+ background-color: #e8ecf2;
+}
+QCheckBox::indicator:checked:hover,
+QRadioButton::indicator:checked:hover {
+ border-color: #14c7a4;
+}
+
+/* Tree / List / Table Widget CheckBox Indicator */
+QTreeWidget::indicator,
+QListWidget::indicator,
+QTableWidget::indicator {
+ border: 2px solid #a0b4cc;
+ border-radius: 3px;
+ background-color: #e8ecf2;
+}
+QTreeWidget::indicator:hover,
+QListWidget::indicator:hover,
+QTableWidget::indicator:hover {
+ border-color: #14c7a4;
+}
+QTreeWidget::indicator:checked,
+QListWidget::indicator:checked,
+QTableWidget::indicator:checked {
+ background-color: #0ea58a;
+ border-color: #0ea58a;
+}
+QTreeWidget::indicator:checked:hover,
+QListWidget::indicator:checked:hover,
+QTableWidget::indicator:checked:hover {
+ border-color: #14c7a4;
+}
+QTreeWidget::indicator:disabled,
+QListWidget::indicator:disabled,
+QTableWidget::indicator:disabled {
+ background-color: #d5dde8;
+ border-color: #c0cdda;
+}
+QTreeWidget::indicator:indeterminate,
+QListWidget::indicator:indeterminate,
+QTableWidget::indicator:indeterminate {
+ background-color: #0ea58a;
+ border-color: #14c7a4;
+}
+
+/* ---- Group Box ---- */
+QGroupBox {
+ margin-top: 5px;
+ padding-top: 15px;
+ color: #4a6080;
+ font-weight: bold;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+}
+
+/* ---- Tab ---- */
+QTabWidget::pane {
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+ background-color: #f0f4f8;
+ top: -1px;
+}
+QTabBar::tab {
+ background-color: #e0e6ee;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-bottom: none;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ padding: 6px 16px;
+ margin-right: 2px;
+ color: #6a7898;
+}
+QTabBar::tab:selected {
+ background-color: #f0f4f8;
+ color: #0ea58a;
+ border-bottom: 2px solid #0ea58a;
+}
+
+/* ---- List / Tree ---- */
+QListWidget,
+QTreeWidget,
+QTableWidget {
+ background-color: #ffffff;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+ outline: none;
+ color: #1a2740;
+ alternate-background-color: #f4f7fa;
+}
+QListWidget::item,
+QTreeWidget::item,
+QTableWidget::item {
+ padding: 5px 5px;
+}
+QHeaderView::section {
+ background-color: #dce4ee;
+ border-right: 1px solid #c0cdda;
+ border-bottom: 1px solid #c0cdda;
+ padding: 5px 10px;
+ color: #4a6080;
+ font-weight: bold;
+}
+
+/* ---- Scroll Bar ---- */
+QScrollBar:vertical {
+ background-color: #eef2f6;
+ width: 10px;
+ border-radius: 5px;
+}
+QScrollBar::handle:vertical {
+ background-color: #a0b4cc;
+ min-height: 30px;
+ border-radius: 5px;
+}
+QScrollBar::handle:vertical:hover {
+ background-color: #8098b8;
+}
+QScrollBar::add-line:vertical,
+QScrollBar::sub-line:vertical {
+ height: 0;
+}
+QScrollBar:horizontal {
+ background-color: #eef2f6;
+ height: 10px;
+ border-radius: 5px;
+}
+QScrollBar::handle:horizontal {
+ background-color: #a0b4cc;
+ min-width: 30px;
+ border-radius: 5px;
+}
+QScrollBar::handle:horizontal:hover {
+ background-color: #8098b8;
+}
+QScrollBar::add-line:horizontal,
+QScrollBar::sub-line:horizontal {
+ width: 0;
+}
+
+/* ---- Progress Bar ---- */
+QProgressBar {
+ background-color: #ffffff;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 5px;
+ height: 10px;
+ text-align: center;
+ color: #1a2740;
+}
+QProgressBar::chunk {
+ background-color: #0ea58a;
+ border-radius: 4px;
+}
+
+/* ---- Slider ---- */
+QSlider::groove:horizontal {
+ background-color: #d5dde8;
+ height: 6px;
+ border-radius: 3px;
+}
+QSlider::handle:horizontal {
+ background-color: #0ea58a;
+ width: 16px;
+ height: 16px;
+ margin: -5px 0;
+ border-radius: 8px;
+}
+QSlider::sub-page:horizontal {
+ background-color: #0ea58a;
+ border-radius: 3px;
+}
+QSlider::handle:horizontal:disabled {
+ background-color: #98a8c0;
+}
+QSlider::sub-page:horizontal:disabled {
+ background-color: #98a8c0;
+}
+
+/* ---- Tool Tip ---- */
+QToolTip {
+ background-color: #d5dde8;
+ border-style: solid;
+ border-color: #0ea58a;
+ border-width: 1px;
+ border-radius: 4px;
+ padding: 4px 8px;
+ color: #1a2740;
+}
+
+/* ---- Status Bar ---- */
+QStatusBar {
+ background-color: #e8ecf2;
+ border-top: 1px solid #c0cdda;
+ color: #6a7898;
+}
+
+/* ---- Splitter ---- */
+QSplitter::handle {
+ background-color: #c0cdda;
+ margin: 1px;
+}
+QSplitter::handle:horizontal {
+ width: 2px;
+}
+QSplitter::handle:vertical {
+ height: 2px;
+}
+
+/* ---- Dialog ---- */
+QDialog {
+ background-color: #f0f4f8;
+}
+
+/* ---- Date / Time Editor Drop-down ---- */
+QDateEdit::drop-down,
+QTimeEdit::drop-down {
+ subcontrol-origin: padding;
+ subcontrol-position: top right;
+ width: 24px;
+ border-left: 1px solid #c0cdda;
+}
+QCalendarWidget {
+ background-color: #ffffff;
+ border-style: solid;
+ border-color: #c0cdda;
+ border-width: 1px;
+ border-radius: 6px;
+}
+QCalendarWidget QToolButton {
+ color: #1a2740;
+ border-radius: 4px;
+ padding: 4px 8px;
+}
+QCalendarWidget QToolButton:hover {
+ background-color: #d5dde8;
+}
+QCalendarWidget QMenu {
+ background-color: #ffffff;
+}
+
+/* ---- Frame ---- */
+QFrame[frameShape="4"], /* HLine */
+QFrame[frameShape="5"] /* VLine */ {
+ background-color: #c0cdda;
+}
diff --git a/src/gui/resources/ui/ALConfigWidget.ui b/src/gui/resources/ui/ALConfigWidget.ui
index a272576..58db0b8 100644
--- a/src/gui/resources/ui/ALConfigWidget.ui
+++ b/src/gui/resources/ui/ALConfigWidget.ui
@@ -1956,13 +1956,13 @@
- 100
+ 120
25
- 100
+ 120
25
diff --git a/src/gui/resources/ui/ALMainWindow.ui b/src/gui/resources/ui/ALMainWindow.ui
index c210a8b..84aa38c 100644
--- a/src/gui/resources/ui/ALMainWindow.ui
+++ b/src/gui/resources/ui/ALMainWindow.ui
@@ -281,6 +281,12 @@ font: 700 9pt;
true
+
+
@@ -308,6 +315,11 @@ font: 700 9pt;
关于
+
+
+ 全局设置
+
+
diff --git a/src/gui/resources/ui/ALSettingsWidget.ui b/src/gui/resources/ui/ALSettingsWidget.ui
new file mode 100644
index 0000000..45ed0e8
--- /dev/null
+++ b/src/gui/resources/ui/ALSettingsWidget.ui
@@ -0,0 +1,539 @@
+
+
+ ALSettingsWidget
+
+
+
+ 0
+ 0
+ 520
+ 420
+
+
+
+
+ 480
+ 420
+
+
+
+
+ 580
+ 420
+
+
+
+ 全局设置 - AutoLibrary
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ 5
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
-
+
+
+
+ 100
+ 0
+
+
+
+
+ 100
+ 16777215
+
+
+
+ Qt::FocusPolicy::StrongFocus
+
+
+ QFrame::Shape::NoFrame
+
+
+ QAbstractItemView::EditTrigger::NoEditTriggers
+
+
+ QAbstractItemView::SelectionMode::SingleSelection
+
+
+
+ 20
+ 20
+
+
+
+ 0
+
+
-
+
+ 外观
+
+
+
+
+
+
+
+ -
+
+
+ QFrame::Shape::NoFrame
+
+
+ true
+
+
+
+
+ 0
+ -51
+ 397
+ 434
+
+
+
+
+ 5
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
-
+
+
+ 主题模式
+
+
+
+ 5
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
-
+
+
+ 浅色
+
+
+
+ -
+
+
+ 深色
+
+
+
+ -
+
+
+ 跟随系统
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+ 界面风格
+
+
+
+ 5
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
-
+
+
+ 5
+
+
-
+
+
+
+ 100
+ 25
+
+
+
+
+ 100
+ 25
+
+
+
+ 应用程序样式:
+
+
+
+ -
+
+
+
+ 160
+ 25
+
+
+
+
+
+
+ -
+
+
+ 更改样式将在下次启动应用程序时生效。
+
+
+
+
+
+
+ -
+
+
+ 自定义外观
+
+
+
+ 5
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
-
+
+
+ 选择一个主题,或导入新的主题文件:
+
+
+ true
+
+
+
+ -
+
+
+ 5
+
+
-
+
+
+
+ 160
+ 25
+
+
+
+
+ -
+
+
+
+ 0
+ 25
+
+
+
+ false
+
+
+ 选择或输入 QSS 样式表文件路径...
+
+
+
+ -
+
+
+
+ 25
+ 25
+
+
+
+
+ 25
+ 25
+
+
+
+ +
+
+
+
+ -
+
+
+
+ 25
+ 25
+
+
+
+
+ 25
+ 25
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+ 0
+ 60
+
+
+
+
+
+
+ Qt::TextFormat::RichText
+
+
+ Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop
+
+
+ true
+
+
+
+ -
+
+
+ 5
+
+
-
+
+
+
+ 80
+ 25
+
+
+
+ 重置主题
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ 当前使用程序 默认 外观。
+
+
+ true
+
+
+
+
+
+
+ -
+
+
+ Qt::Orientation::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ 5
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
+ 3
+
+
-
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+
+ 80
+ 25
+
+
+
+
+ 80
+ 25
+
+
+
+ 取消
+
+
+
+ -
+
+
+
+ 80
+ 25
+
+
+
+
+ 80
+ 25
+
+
+
+ 应用
+
+
+
+ -
+
+
+
+ 80
+ 25
+
+
+
+
+ 80
+ 25
+
+
+
+ 确认
+
+
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/src/gui/resources/ui/ALTimerTaskAddDialog.ui b/src/gui/resources/ui/ALTimerTaskAddDialog.ui
index e2fc6c4..251a4e2 100644
--- a/src/gui/resources/ui/ALTimerTaskAddDialog.ui
+++ b/src/gui/resources/ui/ALTimerTaskAddDialog.ui
@@ -7,13 +7,13 @@
0
0
350
- 400
+ 500
350
- 460
+ 500
diff --git a/src/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py
index 9ee5bb4..d4f180a 100644
--- a/src/interfaces/ConfigProvider.py
+++ b/src/interfaces/ConfigProvider.py
@@ -66,6 +66,12 @@ class CfgKey:
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
+ class APPEARANCE:
+ ROOT = ConfigPath(ConfigType.GLOBAL, "appearance")
+ THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme")
+ STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style")
+ CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme")
+
class TIMERTASK:
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py
index 4f89eb4..0f54f13 100644
--- a/src/managers/config/ConfigManager.py
+++ b/src/managers/config/ConfigManager.py
@@ -54,6 +54,11 @@ class ConfigTemplate:
"current": 0,
"paths": []
}
+ },
+ "appearance": {
+ "theme": "system",
+ "style": "Fusion",
+ "custom_theme": ""
}
}
case ConfigType.BULLETIN:
diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py
new file mode 100644
index 0000000..755c67b
--- /dev/null
+++ b/src/managers/theme/ThemeManager.py
@@ -0,0 +1,355 @@
+# -*- coding: utf-8 -*-
+"""
+Copyright (c) 2026 KenanZhu.
+All rights reserved.
+
+This software is provided "as is", without any warranty of any kind.
+You may use, modify, and distribute this file under the terms of the MIT License.
+See the LICENSE file for details.
+"""
+import os
+import shutil
+import threading
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QApplication,
+ QStyleFactory
+)
+
+from interfaces.ConfigProvider import CfgKey
+from managers.config.ConfigManager import instance as configInstance
+from managers.log.LogManager import instance as logInstance
+from utils.ThemeUtils import (
+ readThemeInfo,
+ readThemeQss,
+ validateTheme,
+ wrapQssToAtheme
+)
+
+
+_active_style_name = "Fusion"
+
+
+def setActiveStyle(
+ style_name: str
+):
+
+ global _active_style_name
+ _active_style_name = style_name
+
+def getActiveStyle(
+) -> str:
+
+ return _active_style_name
+
+
+class ThemeManager:
+ """
+ Theme manager class.
+
+ Manages the themes storage directory, providing import,
+ list, remove, and apply operations for .altheme theme files.
+
+ Args:
+ themes_dir (str): Path to the themes storage directory.
+ """
+
+ def __init__(
+ self,
+ themes_dir: str
+ ):
+
+ self.__themes_dir = os.path.abspath(themes_dir)
+ self.__lock = threading.Lock()
+ self.__current_theme_name = ""
+ os.makedirs(self.__themes_dir, exist_ok=True)
+
+ @staticmethod
+ def _colorSchemeFor(
+ theme: str
+ ) -> Qt.ColorScheme:
+ """
+ Map a theme identifier to the corresponding Qt color scheme.
+ """
+
+ if theme == "dark":
+ return Qt.ColorScheme.Dark
+ elif theme == "light":
+ return Qt.ColorScheme.Light
+ else:
+ return Qt.ColorScheme.Unknown
+
+ def _deleteThemeFile(
+ self,
+ name: str
+ ):
+ """
+ Delete a theme file in the themes storage directory.
+
+ The caller must hold self.__lock before invoking this method.
+
+ **This method ONLY deletes the file**.
+ """
+
+ filepath = os.path.join(self.__themes_dir, name + ".altheme")
+ if os.path.isfile(filepath):
+ os.remove(filepath)
+
+ def _resolveDestPath(
+ self,
+ theme_name: str,
+ author: str
+ ) -> str:
+ """
+ Resolve the destination path for an imported theme.
+
+ If the default {name}.altheme path does not exist, use it directly.
+ If it exists and has a different author, use {name}_{author}.altheme.
+ If it exists and has the same author, raise ValueError.
+
+ Args:
+ theme_name (str): Sanitised theme name.
+ author (str): Theme author string.
+
+ Returns:
+ str: The resolved destination file path.
+
+ Raises:
+ ValueError: If a theme with the same name and author already exists.
+ """
+
+ default_path = os.path.join(self.__themes_dir, theme_name + ".altheme")
+ if not os.path.exists(default_path):
+ return default_path
+ try:
+ existing_info = validateTheme(default_path)
+ existing_author = existing_info.get("author", "")
+ except Exception:
+ self._deleteThemeFile(theme_name) # caller holds the lock
+ raise ValueError(
+ f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件"
+ )
+ if existing_author == author:
+ raise ValueError(
+ f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
+ )
+ safe_author = os.path.basename(author) if author else "未知作者"
+ alt_path = os.path.join(
+ self.__themes_dir, f"{theme_name}_{safe_author}.altheme"
+ )
+ if os.path.exists(alt_path):
+ raise ValueError(
+ f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
+ )
+ return alt_path
+
+ def themesDir(
+ self
+ ) -> str:
+ """
+ Get the themes directory path.
+
+ Returns:
+ str: The absolute path to the themes storage directory.
+ """
+
+ return self.__themes_dir
+
+ def importTheme(
+ self,
+ source_path: str
+ ) -> str:
+ """
+ Import a theme file into the themes directory.
+
+ Supports .altheme (zip archive) and bare .qss files.
+ Bare .qss files are automatically wrapped into .altheme format.
+ For .altheme files, validates that theme.qss exists in the archive
+ and sanitises the theme name to prevent path traversal.
+
+ Args:
+ source_path (str): Path to the .altheme or .qss file.
+
+ Returns:
+ str: The imported theme name.
+
+ Raises:
+ FileNotFoundError: If source_path does not exist.
+ ValueError: If the file type is unsupported or the .altheme is invalid.
+ """
+
+ if not os.path.isfile(source_path):
+ raise FileNotFoundError(source_path)
+ base_name, ext = os.path.splitext(os.path.basename(source_path))
+ ext = ext.lower()
+ with self.__lock:
+ if ext == ".qss":
+ dest_path = self._resolveDestPath(base_name, "未知作者")
+ wrapQssToAtheme(source_path, dest_path, "both")
+ return os.path.splitext(os.path.basename(dest_path))[0]
+ elif ext == ".altheme":
+ info = validateTheme(source_path)
+ name = info.get("name", base_name)
+ safe_name = os.path.basename(name)
+ new_author = info.get("author", "")
+ dest_path = self._resolveDestPath(safe_name, new_author)
+ shutil.copy2(source_path, dest_path)
+ return os.path.splitext(os.path.basename(dest_path))[0]
+ else:
+ raise ValueError(f"不支持的文件类型: {ext}")
+
+ def listThemes(
+ self
+ ) -> list:
+ """
+ List all available themes in the themes directory.
+
+ Scans the themes directory for .altheme files and reads
+ their info.json metadata.
+
+ Returns:
+ list[dict]: A list of theme info dictionaries.
+ """
+
+ themes = []
+ seen_keys = set()
+ if not os.path.isdir(self.__themes_dir):
+ return themes
+ for filename in sorted(os.listdir(self.__themes_dir)):
+ if filename.endswith(".altheme"):
+ filepath = os.path.join(self.__themes_dir, filename)
+ try:
+ info = validateTheme(filepath)
+ name = info.get("name", "")
+ author = info.get("author", "")
+ key = (name, author)
+ if key in seen_keys:
+ logInstance().getLogger("ThemeManager").warning(
+ f"主题名称 '{name}' (作者 '{author}') 重复 (文件 '{filename}') 已跳过"
+ )
+ continue
+ seen_keys.add(key)
+ info["file"] = os.path.splitext(filename)[0]
+ themes.append(info)
+ except Exception as e:
+ logInstance().getLogger("ThemeManager").warning(
+ f"无法读取主题文件 '{filename}',已跳过: {e}"
+ )
+ else:
+ logInstance().getLogger("ThemeManager").warning(
+ f"未知文件类型 '{filename}',已跳过"
+ )
+ return themes
+
+ def removeTheme(
+ self,
+ name: str
+ ):
+ """
+ Remove a theme by name.
+
+ If the removed theme is currently active, clears the QSS
+ stylesheet from the application and reverts to the saved
+ colour scheme.
+
+ Args:
+ name (str): The theme name to remove.
+ """
+
+ with self.__lock:
+ self._deleteThemeFile(name)
+ if self.__current_theme_name == name:
+ self.__current_theme_name = ""
+ saved_theme = configInstance().get(
+ CfgKey.GLOBAL.APPEARANCE.THEME, "system"
+ )
+ self.clearTheme(saved_theme)
+
+ def applyTheme(
+ self,
+ name: str
+ ):
+ """
+ Apply a theme by name.
+
+ Extracts the QSS from the .altheme file, applies it to
+ QApplication, and sets the Qt color scheme based on
+ the theme's need_theme metadata.
+
+ Args:
+ name (str): The theme name to apply.
+
+ Raises:
+ FileNotFoundError: If the theme .altheme file does not exist.
+ """
+
+ filepath = os.path.join(self.__themes_dir, name + ".altheme")
+ if not os.path.isfile(filepath):
+ raise FileNotFoundError(filepath)
+ with self.__lock:
+ info = readThemeInfo(filepath)
+ qss = readThemeQss(filepath)
+ app = QApplication.instance()
+ if app:
+ app.setStyleSheet(qss)
+ need_theme = info.get("need_theme", "both")
+ app.styleHints().setColorScheme(
+ ThemeManager._colorSchemeFor(need_theme)
+ )
+ app.setStyle(QStyleFactory.create(_active_style_name))
+ self.__current_theme_name = name
+
+ def clearTheme(
+ self,
+ theme: str
+ ):
+ """
+ Clear the current QSS stylesheet and apply the given color scheme.
+
+ Args:
+ theme (str): The color scheme to apply after clearing
+ ("light", "dark", or "system").
+ """
+
+ app = QApplication.instance()
+ if not app:
+ return
+ app.setStyleSheet("")
+ app.styleHints().setColorScheme(
+ ThemeManager._colorSchemeFor(theme)
+ )
+ app.setStyle(QStyleFactory.create(_active_style_name))
+
+
+# ThemeManager singleton instance.
+_theme_manager_instance = None
+
+# Singleton instance lock.
+_instance_lock = threading.Lock()
+
+
+def instance(
+ themes_dir: str = ""
+) -> ThemeManager:
+ """
+ Get the ThemeManager singleton instance.
+
+ On first call, initialises the ThemeManager with the themes
+ directory derived from ConfigManager's config directory.
+
+ Args:
+ themes_dir (str): Optional themes directory path.
+
+ Returns:
+ ThemeManager: The singleton ThemeManager instance.
+ """
+
+ global _theme_manager_instance
+ with _instance_lock:
+ if _theme_manager_instance is None:
+ if not themes_dir:
+ cfg = configInstance()
+ themes_dir = os.path.join(cfg.configDir(), "themes")
+ _theme_manager_instance = ThemeManager(themes_dir)
+ return _theme_manager_instance
diff --git a/src/managers/theme/__init__.py b/src/managers/theme/__init__.py
new file mode 100644
index 0000000..5cb1dac
--- /dev/null
+++ b/src/managers/theme/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+"""
+Copyright (c) 2026 KenanZhu.
+All rights reserved.
+
+This software is provided "as is", without any warranty of any kind.
+You may use, modify, and distribute this file under the terms of the MIT License.
+See the LICENSE file for details.
+"""
diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py
new file mode 100644
index 0000000..94ed0d0
--- /dev/null
+++ b/src/utils/ThemeUtils.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+"""
+Copyright (c) 2026 KenanZhu.
+All rights reserved.
+
+This software is provided "as is", without any warranty of any kind.
+You may use, modify, and distribute this file under the terms of the MIT License.
+See the LICENSE file for details.
+"""
+import json
+import os
+import zipfile
+
+
+def packTheme(
+ qss_path: str,
+ info: dict,
+ output_path: str
+):
+ """
+ Pack a .qss file and info dict into a .altheme file.
+
+ The .altheme file is a zip archive containing info.json and theme.qss.
+
+ Args:
+ qss_path (str): Path to the .qss stylesheet file.
+ info (dict): Theme metadata dict with keys name, author, need_theme, brief.
+ output_path (str): Destination path for the .altheme file.
+
+ Raises:
+ FileNotFoundError: If qss_path does not exist.
+ """
+
+ if not os.path.isfile(qss_path):
+ raise FileNotFoundError(qss_path)
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4))
+ zf.write(qss_path, "theme.qss")
+
+def unpackTheme(
+ altheme_path: str,
+ output_dir: str
+):
+ """
+ Extract a .altheme file to a directory.
+
+ Performs Zip Slip validation before extraction.
+
+ Args:
+ altheme_path (str): Path to the .altheme file.
+ output_dir (str): Directory to extract contents into.
+
+ Raises:
+ FileNotFoundError: If altheme_path does not exist.
+ ValueError: If a zip entry contains an unsafe path.
+ """
+
+ if not os.path.isfile(altheme_path):
+ raise FileNotFoundError(altheme_path)
+ os.makedirs(output_dir, exist_ok=True)
+ with zipfile.ZipFile(altheme_path, "r") as zf:
+ for name in zf.namelist():
+ if name.startswith("/") or ".." in name:
+ raise ValueError(f"不安全的 .altheme 入口: {name}")
+ zf.extractall(output_dir)
+
+def readThemeInfo(
+ altheme_path: str
+) -> dict:
+ """
+ Read and validate the info.json metadata from a .altheme file.
+
+ Verifies that all required fields (name, author, need_theme, brief)
+ are present with valid values.
+
+ Args:
+ altheme_path (str): Path to the .altheme file.
+
+ Returns:
+ dict: The validated theme metadata dictionary.
+
+ Raises:
+ FileNotFoundError: If altheme_path does not exist.
+ ValueError: If info.json is missing or any field is invalid.
+ """
+
+ if not os.path.isfile(altheme_path):
+ raise FileNotFoundError(altheme_path)
+ with zipfile.ZipFile(altheme_path, "r") as zf:
+ if "info.json" not in zf.namelist():
+ raise ValueError("无效的 .altheme: 缺少 info.json")
+ with zf.open("info.json") as fh:
+ try:
+ info = json.loads(fh.read().decode("utf-8"))
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
+ raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}")
+ if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip():
+ raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段")
+ # reject blank author so that info.json does not drift from the
+ # "未知作者" filename fallback used by wrapQssToAtheme
+ if ("author" not in info or not isinstance(info.get("author"), str)
+ or not info["author"].strip()):
+ raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段")
+ need_theme = info.get("need_theme", "both")
+ if need_theme not in ("light", "dark", "both"):
+ raise ValueError(
+ f"无效的 .altheme: need_theme 值 '{need_theme}' 无效, "
+ f"应为 'light'、'dark' 或 'both'"
+ )
+ if "brief" not in info or not isinstance(info.get("brief"), str):
+ raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段")
+ return info
+
+def readThemeQss(
+ altheme_path: str
+) -> str:
+ """
+ Read the theme.qss content from a .altheme archive.
+
+ Args:
+ altheme_path (str): Path to the .altheme file.
+
+ Returns:
+ str: The non-empty QSS stylesheet content.
+
+ Raises:
+ FileNotFoundError: If altheme_path does not exist.
+ ValueError: If theme.qss is missing or empty.
+ """
+
+ if not os.path.isfile(altheme_path):
+ raise FileNotFoundError(altheme_path)
+ with zipfile.ZipFile(altheme_path, "r") as zf:
+ if "theme.qss" not in zf.namelist():
+ raise ValueError("无效的 .altheme: 缺少 theme.qss")
+ qss = zf.read("theme.qss").decode("utf-8")
+ if not qss.strip():
+ raise ValueError("无效的 .altheme: theme.qss 为空")
+ return qss
+
+def validateTheme(
+ altheme_path: str
+) -> dict:
+ """
+ Fully validate a .altheme file and return its metadata.
+
+ Delegates info validation to readThemeInfo and QSS validation
+ to readThemeQss, then additionally checks that theme.qss is
+ non-empty.
+
+ Args:
+ altheme_path (str): Path to the .altheme file.
+
+ Returns:
+ dict: The validated theme metadata dictionary.
+
+ Raises:
+ FileNotFoundError: If altheme_path does not exist.
+ ValueError: If validation fails for any reason.
+ """
+
+ info = readThemeInfo(altheme_path)
+ readThemeQss(altheme_path) # validates existence and non-empty
+ return info
+
+def wrapQssToAtheme(
+ qss_path: str,
+ output_path: str,
+ current_theme: str
+):
+ """
+ Wrap a bare .qss file into a .altheme file with auto-generated metadata.
+
+ The generated info.json uses the filename as the theme name
+ and sets default values for author and brief.
+
+ Args:
+ qss_path (str): Path to the bare .qss stylesheet file.
+ output_path (str): Destination path for the .altheme file.
+ current_theme (str): The need_theme value to embed in metadata
+ ("light", "dark", or "both").
+
+ Raises:
+ FileNotFoundError: If qss_path does not exist.
+ """
+
+ filename = os.path.splitext(os.path.basename(qss_path))[0]
+ info = {
+ "name": filename,
+ "author": "未知作者",
+ "need_theme": current_theme,
+ "brief": "没有相关简介"
+ }
+ packTheme(qss_path, info, output_path)