mirror of
https://github.com/KenanZhu/AutoLibrary.git
synced 2026-06-18 15:33: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:
@@ -81,8 +81,16 @@ def _initializeAppearance(
|
|||||||
saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
|
saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
|
||||||
saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
|
saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
|
||||||
saved_qss = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "")
|
saved_qss = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "")
|
||||||
|
saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
|
||||||
app.setStyle(saved_style)
|
app.setStyle(saved_style)
|
||||||
_setActiveStyleName(saved_style)
|
_setActiveStyleName(saved_style)
|
||||||
|
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)
|
_applyQss(saved_qss)
|
||||||
_applyTheme(saved_theme)
|
_applyTheme(saved_theme)
|
||||||
|
|
||||||
|
|||||||
+96
-44
@@ -24,6 +24,7 @@ from PySide6.QtGui import (
|
|||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
QStyleFactory,
|
QStyleFactory,
|
||||||
@@ -31,6 +32,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
import managers.config.ConfigManager as ConfigManager
|
import managers.config.ConfigManager as ConfigManager
|
||||||
|
from managers.theme.ThemeManager import instance as themeInstance
|
||||||
|
|
||||||
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
|
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
|
||||||
from interfaces.ConfigProvider import (
|
from interfaces.ConfigProvider import (
|
||||||
@@ -56,6 +58,18 @@ def _clearQss(
|
|||||||
if app:
|
if app:
|
||||||
app.setStyleSheet("")
|
app.setStyleSheet("")
|
||||||
|
|
||||||
|
def _applyThemeByName(
|
||||||
|
name: str
|
||||||
|
):
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
_clearQss()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
themeInstance().applyTheme(name)
|
||||||
|
except Exception:
|
||||||
|
_clearQss()
|
||||||
|
|
||||||
def _loadQss(
|
def _loadQss(
|
||||||
file_path: str
|
file_path: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -129,6 +143,15 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
self.NavigationList.setCurrentRow(0)
|
self.NavigationList.setCurrentRow(0)
|
||||||
self.populateStyles()
|
self.populateStyles()
|
||||||
self.setNavigationIcons()
|
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(
|
def setNavigationIcons(
|
||||||
self
|
self
|
||||||
@@ -157,8 +180,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
self.BrowseQssButton.clicked.connect(self.onBrowseQssButtonClicked)
|
self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked)
|
||||||
self.ApplyQssButton.clicked.connect(self.onApplyQssButtonClicked)
|
self.ThemeComboBox.currentTextChanged.connect(self.onThemeComboBoxChanged)
|
||||||
self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked)
|
self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked)
|
||||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||||
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
|
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")
|
theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
|
||||||
style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
|
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()
|
self.__original_style = self.currentStyleKey()
|
||||||
if theme == "light":
|
if theme == "light":
|
||||||
self.LightThemeRadio.setChecked(True)
|
self.LightThemeRadio.setChecked(True)
|
||||||
@@ -211,19 +234,22 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
if index < 0:
|
if index < 0:
|
||||||
index = 0
|
index = 0
|
||||||
self.StyleComboBox.setCurrentIndex(index)
|
self.StyleComboBox.setCurrentIndex(index)
|
||||||
self.QssPathEdit.setText(custom_qss)
|
self.populateThemeList()
|
||||||
self.updateQssStatus(custom_qss)
|
if custom_theme:
|
||||||
|
idx = self.ThemeComboBox.findText(custom_theme)
|
||||||
|
if idx >= 0:
|
||||||
|
self.ThemeComboBox.setCurrentIndex(idx)
|
||||||
|
self.updateThemeStatus()
|
||||||
|
|
||||||
def updateQssStatus(
|
def updateThemeStatus(
|
||||||
self,
|
self
|
||||||
qss_path: str
|
|
||||||
):
|
):
|
||||||
|
|
||||||
if qss_path and os.path.isfile(qss_path):
|
name = self.ThemeComboBox.currentText()
|
||||||
filename = os.path.basename(qss_path)
|
if name:
|
||||||
self.QssStatusLabel.setText(f"已加载自定义样式文件:{filename}")
|
self.ThemeStatusLabel.setText(f"已加载主题:{name}")
|
||||||
else:
|
else:
|
||||||
self.QssStatusLabel.setText("当前使用程序默认外观。")
|
self.ThemeStatusLabel.setText("当前使用程序默认外观。")
|
||||||
|
|
||||||
def collectSettings(
|
def collectSettings(
|
||||||
self
|
self
|
||||||
@@ -236,21 +262,21 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
else:
|
else:
|
||||||
theme = "system"
|
theme = "system"
|
||||||
style = self.StyleComboBox.currentText()
|
style = self.StyleComboBox.currentText()
|
||||||
custom_qss = self.QssPathEdit.text().strip()
|
custom_theme = self.ThemeComboBox.currentText()
|
||||||
return theme, style, custom_qss
|
return theme, style, custom_theme
|
||||||
|
|
||||||
def saveAndApply(
|
def saveAndApply(
|
||||||
self
|
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.THEME, theme)
|
||||||
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style)
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style)
|
||||||
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, custom_qss)
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme)
|
||||||
_applyQss(custom_qss)
|
_applyThemeByName(custom_theme)
|
||||||
_applyTheme(theme)
|
_applyTheme(theme)
|
||||||
self.setNavigationIcons()
|
self.setNavigationIcons()
|
||||||
self.updateQssStatus(custom_qss)
|
self.updateThemeStatus()
|
||||||
self.__original_style = self.currentStyleKey()
|
self.__original_style = self.currentStyleKey()
|
||||||
|
|
||||||
def maybeRestart(
|
def maybeRestart(
|
||||||
@@ -269,49 +295,75 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
return True
|
return True
|
||||||
return False
|
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()
|
@Slot()
|
||||||
def onBrowseQssButtonClicked(
|
def onImportThemeButtonClicked(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
file_path, _ = QFileDialog.getOpenFileName(
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
self,
|
self,
|
||||||
"选择 QSS 样式文件 - AutoLibrary",
|
"导入主题 - AutoLibrary",
|
||||||
self.QssPathEdit.text(),
|
"",
|
||||||
"QSS 样式表文件 (*.qss);;所有文件 (*)"
|
"主题文件 (*.altheme *.qss);;所有文件 (*)"
|
||||||
|
)
|
||||||
|
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}"
|
||||||
)
|
)
|
||||||
if file_path:
|
|
||||||
self.QssPathEdit.setText(file_path)
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def onApplyQssButtonClicked(
|
def onThemeComboBoxChanged(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
qss_path = self.QssPathEdit.text().strip()
|
name = self.ThemeComboBox.currentText()
|
||||||
if not qss_path:
|
if name:
|
||||||
QMessageBox.warning(
|
_applyThemeByName(name)
|
||||||
self,
|
t = self.__theme_cache.get(name)
|
||||||
"提示 - AutoLibrary",
|
if t:
|
||||||
"请先选择或输入 QSS 样式表文件路径。"
|
need_theme = t.get("need_theme", "both")
|
||||||
)
|
if need_theme == "light":
|
||||||
return
|
self.LightThemeRadio.setChecked(True)
|
||||||
if not os.path.isfile(qss_path):
|
elif need_theme == "dark":
|
||||||
QMessageBox.warning(
|
self.DarkThemeRadio.setChecked(True)
|
||||||
self,
|
else:
|
||||||
"警告 - AutoLibrary",
|
_clearQss()
|
||||||
f"未找到指定的样式文件:\n{qss_path}"
|
self.updateThemeStatus()
|
||||||
)
|
|
||||||
return
|
|
||||||
_applyQss(qss_path)
|
|
||||||
self.updateQssStatus(qss_path)
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def onResetQssButtonClicked(
|
def onResetQssButtonClicked(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
self.QssPathEdit.clear()
|
self.ThemeComboBox.setCurrentIndex(0)
|
||||||
_clearQss()
|
_clearQss()
|
||||||
if self.LightThemeRadio.isChecked():
|
if self.LightThemeRadio.isChecked():
|
||||||
_applyTheme("light")
|
_applyTheme("light")
|
||||||
@@ -320,7 +372,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
else:
|
else:
|
||||||
_applyTheme("system")
|
_applyTheme("system")
|
||||||
self.setNavigationIcons()
|
self.setNavigationIcons()
|
||||||
self.updateQssStatus("")
|
self.updateThemeStatus()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def onCancelButtonClicked(
|
def onCancelButtonClicked(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ class CfgKey:
|
|||||||
THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme")
|
THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme")
|
||||||
STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style")
|
STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style")
|
||||||
CUSTOM_QSS = ConfigPath(ConfigType.GLOBAL, "appearance.custom_qss")
|
CUSTOM_QSS = ConfigPath(ConfigType.GLOBAL, "appearance.custom_qss")
|
||||||
|
CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme")
|
||||||
|
|
||||||
class TIMERTASK:
|
class TIMERTASK:
|
||||||
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
|
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ class ConfigTemplate:
|
|||||||
"appearance": {
|
"appearance": {
|
||||||
"theme": "system",
|
"theme": "system",
|
||||||
"style": "Fusion",
|
"style": "Fusion",
|
||||||
"custom_qss": ""
|
"custom_qss": "",
|
||||||
|
"custom_theme": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case ConfigType.BULLETIN:
|
case ConfigType.BULLETIN:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user