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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
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, "")
|
||||
|
||||
@@ -58,7 +58,8 @@ class ConfigTemplate:
|
||||
"appearance": {
|
||||
"theme": "system",
|
||||
"style": "Fusion",
|
||||
"custom_qss": ""
|
||||
"custom_qss": "",
|
||||
"custom_theme": ""
|
||||
}
|
||||
}
|
||||
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