1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-17 23:13:03 +08:00

feat(theme): 引入 .altheme 主题文件格式与主题管理系统

- 新增 .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 防护与线程安全保护
This commit is contained in:
2026-05-30 21:01:18 +08:00
parent c0b6e0899c
commit 35253dadbb
8 changed files with 913 additions and 46 deletions
+9 -1
View File
@@ -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(
+96 -44
View File
@@ -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(
+421
View File
@@ -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;
}
+1
View File
@@ -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, "")
+2 -1
View File
@@ -58,7 +58,8 @@ class ConfigTemplate:
"appearance": {
"theme": "system",
"style": "Fusion",
"custom_qss": ""
"custom_qss": "",
"custom_theme": ""
}
}
case ConfigType.BULLETIN:
+252
View File
@@ -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
+9
View File
@@ -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.
"""
+123
View File
@@ -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)