diff --git a/src/boot/AppInitializer.py b/src/boot/AppInitializer.py index 11f8a50..a4bd884 100644 --- a/src/boot/AppInitializer.py +++ b/src/boot/AppInitializer.py @@ -81,9 +81,17 @@ def _initializeAppearance( saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") saved_qss = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") + saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") app.setStyle(saved_style) _setActiveStyleName(saved_style) - _applyQss(saved_qss) + if saved_custom_theme: + try: + from managers.theme.ThemeManager import instance as themeInstance + themeInstance().applyTheme(saved_custom_theme) + except Exception: + _applyQss(saved_qss) + else: + _applyQss(saved_qss) _applyTheme(saved_theme) def initializeApp( diff --git a/src/gui/ALSettingsWidget.py b/src/gui/ALSettingsWidget.py index 6ce37d2..44b58a6 100644 --- a/src/gui/ALSettingsWidget.py +++ b/src/gui/ALSettingsWidget.py @@ -24,6 +24,7 @@ from PySide6.QtGui import ( ) from PySide6.QtWidgets import ( QApplication, + QComboBox, QFileDialog, QMessageBox, QStyleFactory, @@ -31,6 +32,7 @@ from PySide6.QtWidgets import ( ) import managers.config.ConfigManager as ConfigManager +from managers.theme.ThemeManager import instance as themeInstance from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget from interfaces.ConfigProvider import ( @@ -56,6 +58,18 @@ def _clearQss( if app: app.setStyleSheet("") +def _applyThemeByName( + name: str +): + + if not name: + _clearQss() + return + try: + themeInstance().applyTheme(name) + except Exception: + _clearQss() + def _loadQss( file_path: str ) -> str: @@ -129,6 +143,15 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self.NavigationList.setCurrentRow(0) self.populateStyles() self.setNavigationIcons() + self.QssPathEdit.hide() + self.ApplyQssButton.hide() + self.ResetQssButton.setText("重置主题") + self.CustomQssHintLabel.setText("选择一个主题,或导入新的主题文件:") + self.ThemeComboBox = QComboBox(self.CustomQssGroupBox) + self.ThemeComboBox.setObjectName("ThemeComboBox") + self.ThemeComboBox.setMinimumSize(160, 25) + self.QssPathLayout.insertWidget(0, self.ThemeComboBox) + self.ThemeStatusLabel = self.QssStatusLabel def setNavigationIcons( self @@ -157,8 +180,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): self ): - self.BrowseQssButton.clicked.connect(self.onBrowseQssButtonClicked) - self.ApplyQssButton.clicked.connect(self.onApplyQssButtonClicked) + self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked) + self.ThemeComboBox.currentTextChanged.connect(self.onThemeComboBoxChanged) self.ResetQssButton.clicked.connect(self.onResetQssButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.ApplyButton.clicked.connect(self.onApplyButtonClicked) @@ -199,7 +222,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system") style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion") - custom_qss = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, "") + custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "") self.__original_style = self.currentStyleKey() if theme == "light": self.LightThemeRadio.setChecked(True) @@ -211,19 +234,22 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): if index < 0: index = 0 self.StyleComboBox.setCurrentIndex(index) - self.QssPathEdit.setText(custom_qss) - self.updateQssStatus(custom_qss) + self.populateThemeList() + if custom_theme: + idx = self.ThemeComboBox.findText(custom_theme) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + self.updateThemeStatus() - def updateQssStatus( - self, - qss_path: str + def updateThemeStatus( + self ): - if qss_path and os.path.isfile(qss_path): - filename = os.path.basename(qss_path) - self.QssStatusLabel.setText(f"已加载自定义样式文件:{filename}") + name = self.ThemeComboBox.currentText() + if name: + self.ThemeStatusLabel.setText(f"已加载主题:{name}") else: - self.QssStatusLabel.setText("当前使用程序默认外观。") + self.ThemeStatusLabel.setText("当前使用程序默认外观。") def collectSettings( self @@ -236,21 +262,21 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: theme = "system" style = self.StyleComboBox.currentText() - custom_qss = self.QssPathEdit.text().strip() - return theme, style, custom_qss + custom_theme = self.ThemeComboBox.currentText() + return theme, style, custom_theme def saveAndApply( self ): - theme, style, custom_qss = self.collectSettings() + theme, style, custom_theme = self.collectSettings() self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme) self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style) - self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_QSS, custom_qss) - _applyQss(custom_qss) + self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme) + _applyThemeByName(custom_theme) _applyTheme(theme) self.setNavigationIcons() - self.updateQssStatus(custom_qss) + self.updateThemeStatus() self.__original_style = self.currentStyleKey() def maybeRestart( @@ -269,49 +295,75 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): return True return False + def populateThemeList( + self + ): + + self.ThemeComboBox.blockSignals(True) + self.ThemeComboBox.clear() + self.ThemeComboBox.addItem("") + self.__theme_cache = {} + themes = themeInstance().listThemes() + for t in themes: + name = t.get("name", f"未知主题 {len(self.__theme_cache)+1}") + if name: + self.__theme_cache[name] = t + self.ThemeComboBox.addItem(name) + self.ThemeComboBox.blockSignals(False) + @Slot() - def onBrowseQssButtonClicked( + def onImportThemeButtonClicked( self ): file_path, _ = QFileDialog.getOpenFileName( self, - "选择 QSS 样式文件 - AutoLibrary", - self.QssPathEdit.text(), - "QSS 样式表文件 (*.qss);;所有文件 (*)" + "导入主题 - AutoLibrary", + "", + "主题文件 (*.altheme *.qss);;所有文件 (*)" ) - if file_path: - self.QssPathEdit.setText(file_path) + if not file_path: + return + try: + name = themeInstance().importTheme(file_path) + self.populateThemeList() + idx = self.ThemeComboBox.findText(name) + if idx >= 0: + self.ThemeComboBox.setCurrentIndex(idx) + _applyThemeByName(name) + self.updateThemeStatus() + except Exception as e: + QMessageBox.warning( + self, + "导入失败 - AutoLibrary", + f"无法导入主题文件:{e}" + ) @Slot() - def onApplyQssButtonClicked( + def onThemeComboBoxChanged( self ): - qss_path = self.QssPathEdit.text().strip() - if not qss_path: - QMessageBox.warning( - self, - "提示 - AutoLibrary", - "请先选择或输入 QSS 样式表文件路径。" - ) - return - if not os.path.isfile(qss_path): - QMessageBox.warning( - self, - "警告 - AutoLibrary", - f"未找到指定的样式文件:\n{qss_path}" - ) - return - _applyQss(qss_path) - self.updateQssStatus(qss_path) + name = self.ThemeComboBox.currentText() + if name: + _applyThemeByName(name) + t = self.__theme_cache.get(name) + if t: + need_theme = t.get("need_theme", "both") + if need_theme == "light": + self.LightThemeRadio.setChecked(True) + elif need_theme == "dark": + self.DarkThemeRadio.setChecked(True) + else: + _clearQss() + self.updateThemeStatus() @Slot() def onResetQssButtonClicked( self ): - self.QssPathEdit.clear() + self.ThemeComboBox.setCurrentIndex(0) _clearQss() if self.LightThemeRadio.isChecked(): _applyTheme("light") @@ -320,7 +372,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget): else: _applyTheme("system") self.setNavigationIcons() - self.updateQssStatus("") + self.updateThemeStatus() @Slot() def onCancelButtonClicked( diff --git a/src/gui/resources/themes/LightLake.qss b/src/gui/resources/themes/LightLake.qss new file mode 100644 index 0000000..beb3f3a --- /dev/null +++ b/src/gui/resources/themes/LightLake.qss @@ -0,0 +1,421 @@ +/* + * Copyright (c) 2026 KenanZhu. + * All rights reserved. + * + * This software is provided "as is", without any warranty of any kind. + * You may use, modify, and distribute this file under the terms of the MIT License. + * See the LICENSE file for details. + * + * + * AutoLibrary Official Style Theme : LightLake + */ + +/* ---- Global ---- */ +QMainWindow::separator { + background-color: #c0cdda; + width: 1px; + height: 1px; +} + +/* ---- Menu Bar ---- */ +QMenuBar { + background-color: #dce4ee; + border-bottom: 1px solid #c0cdda; + padding: 2px 6px; + color: #1a2740; +} +QMenuBar::item { + padding: 4px 10px; + border-radius: 4px; +} +QMenuBar::item:selected { + background-color: #d5dde8; +} +QMenu { + background-color: #ffffff; + border-style: solid; + border-color: #d0d8e4; + border-width: 1px; + padding: 4px; + border-radius: 6px; +} +QMenu::item { + padding: 5px 15px 5px 10px; + border-radius: 4px; +} +QMenu::item:selected { + background-color: #0ea58a; + color: #ffffff; +} +QMenu::separator { + height: 1px; + background-color: #d0d8e4; + margin: 4px 8px; +} + +/* ---- Button ---- */ +QPushButton { + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + color: #1a2740; + padding: 4px 12px; + background-color: #d5dde8; +} +QPushButton:hover { + background-color: #c8d4e2; + border-color: #90a4c4; +} +QPushButton:pressed { + background-color: #e2e8f0; + border-color: #0ea58a; +} +QPushButton:disabled { + background-color: #e8ecf2; + color: #98a8c0; + border-color: #d5dde8; +} +QPushButton[default="true"] { + background-color: #0ea58a; + color: #ffffff; + border-color: #0ea58a; +} +QPushButton[default="true"]:hover { + background-color: #14c7a4; +} + +/* ---- Input ---- */ +QLineEdit, +QPlainTextEdit, +QTextEdit, +QSpinBox, +QDoubleSpinBox, +QDateEdit, +QTimeEdit { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + padding: 4px 8px; + color: #1a2740; + selection-background-color: #0ea58a; + selection-color: #ffffff; +} +QLineEdit:focus, +QPlainTextEdit:focus, +QTextEdit:focus, +QSpinBox:focus, +QDoubleSpinBox:focus, +QDateEdit:focus, +QTimeEdit:focus { + border-color: #0ea58a; +} +QPlainTextEdit, +QTextEdit { + background-color: #ffffff; +} + +/* ---- Combo Box ---- */ +QComboBox { + background-color: #d5dde8; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + padding: 4px 10px; + color: #1a2740; +} +QComboBox:hover { + border-color: #90a4c4; +} +QComboBox:focus { + border-color: #0ea58a; +} +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #c0cdda; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} +QComboBox::down-arrow { + image: none; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 6px solid #6a7898; + margin-right: 6px; +} +QComboBox QAbstractItemView { + background-color: #ffffff; + border-style: solid; + border-color: #d0d8e4; + border-width: 1px; + border-radius: 4px; + selection-background-color: #0ea58a; + selection-color: #ffffff; + outline: none; +} + +/* ---- Check Box / Radio Button ---- */ +QCheckBox, +QRadioButton { + spacing: 8px; + color: #1a2740; +} +QCheckBox::indicator, +QRadioButton::indicator { + border-style: solid; + border-color: #90a4c4; + border-width: 2px; + border-radius: 3px; + background-color: #ffffff; +} +QCheckBox::indicator:hover, +QRadioButton::indicator:hover { + border-color: #0ea58a; +} +QCheckBox::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QRadioButton::indicator { + border-radius: 10px; +} +QRadioButton::indicator:checked { + background-color: #0ea58a; + border-color: #0ea58a; +} +QCheckBox::indicator:disabled, +QRadioButton::indicator:disabled { + border-color: #c0cdda; + background-color: #e8ecf2; +} + +/* ---- Group Box ---- */ +QGroupBox { + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 6px; + margin-top: 12px; + padding-top: 14px; + color: #1a2740; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 12px; + padding: 0 6px; + color: #4a6080; +} + +/* ---- Tab ---- */ +QTabWidget::pane { + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + background-color: #f0f4f8; + top: -1px; +} +QTabBar::tab { + background-color: #e0e6ee; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-bottom: none; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + padding: 6px 16px; + margin-right: 2px; + color: #6a7898; +} +QTabBar::tab:selected { + background-color: #f0f4f8; + color: #0ea58a; + border-bottom: 2px solid #0ea58a; +} +QTabBar::tab:hover:!selected { + background-color: #d5dde8; + color: #1a2740; +} + +/* ---- List / Tree ---- */ +QListWidget, +QTreeWidget, +QTableWidget { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + outline: none; + color: #1a2740; + alternate-background-color: #f4f7fa; +} +QListWidget::item, +QTreeWidget::item, +QTableWidget::item { + padding: 5px 10px; + border: none; +} +QListWidget::item:selected, +QTreeWidget::item:selected, +QTableWidget::item:selected { + background-color: #0ea58a; + color: #ffffff; +} +QListWidget::item:hover:!selected, +QTreeWidget::item:hover:!selected { + background-color: #e2e8f0; +} +QHeaderView::section { + background-color: #dce4ee; + border: none; + border-right: 1px solid #c0cdda; + border-bottom: 1px solid #c0cdda; + padding: 6px 10px; + color: #4a6080; + font-weight: bold; +} + +/* ---- Scroll Bar ---- */ +QScrollBar:vertical { + background-color: #eef2f6; + width: 10px; + border-radius: 5px; +} +QScrollBar::handle:vertical { + background-color: #a0b4cc; + min-height: 30px; + border-radius: 5px; +} +QScrollBar::handle:vertical:hover { + background-color: #8098b8; +} +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical { + height: 0; +} +QScrollBar:horizontal { + background-color: #eef2f6; + height: 10px; + border-radius: 5px; +} +QScrollBar::handle:horizontal { + background-color: #a0b4cc; + min-width: 30px; + border-radius: 5px; +} +QScrollBar::handle:horizontal:hover { + background-color: #8098b8; +} +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal { + width: 0; +} + +/* ---- Progress Bar ---- */ +QProgressBar { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 5px; + height: 10px; + text-align: center; + color: #1a2740; +} +QProgressBar::chunk { + background-color: #0ea58a; + border-radius: 4px; +} + +/* ---- Slider ---- */ +QSlider::groove:horizontal { + background-color: #d5dde8; + height: 6px; + border-radius: 3px; +} +QSlider::handle:horizontal { + background-color: #0ea58a; + width: 16px; + height: 16px; + margin: -5px 0; + border-radius: 8px; +} +QSlider::sub-page:horizontal { + background-color: #0ea58a; + border-radius: 3px; +} + +/* ---- Tool Tip ---- */ +QToolTip { + background-color: #d5dde8; + border-style: solid; + border-color: #0ea58a; + border-width: 1px; + border-radius: 4px; + padding: 4px 8px; + color: #1a2740; +} + +/* ---- Status Bar ---- */ +QStatusBar { + background-color: #e8ecf2; + border-top: 1px solid #c0cdda; + color: #6a7898; +} + +/* ---- Splitter ---- */ +QSplitter::handle { + background-color: #c0cdda; + margin: 1px; +} +QSplitter::handle:horizontal { + width: 2px; +} +QSplitter::handle:vertical { + height: 2px; +} + +/* ---- Dialog ---- */ +QDialog { + background-color: #f0f4f8; +} + +/* ---- Date / Time Editor Drop-down ---- */ +QDateEdit::drop-down, +QTimeEdit::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 24px; + border-left: 1px solid #c0cdda; +} +QCalendarWidget { + background-color: #ffffff; + border-style: solid; + border-color: #c0cdda; + border-width: 1px; + border-radius: 6px; +} +QCalendarWidget QToolButton { + color: #1a2740; + border-radius: 4px; + padding: 4px 8px; +} +QCalendarWidget QToolButton:hover { + background-color: #d5dde8; +} +QCalendarWidget QMenu { + background-color: #ffffff; +} + +/* ---- Frame ---- */ +QFrame[frameShape="4"], /* HLine */ +QFrame[frameShape="5"] /* VLine */ { + background-color: #c0cdda; +} diff --git a/src/interfaces/ConfigProvider.py b/src/interfaces/ConfigProvider.py index 51e0d2a..a4a50ed 100644 --- a/src/interfaces/ConfigProvider.py +++ b/src/interfaces/ConfigProvider.py @@ -71,6 +71,7 @@ class CfgKey: THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme") STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style") CUSTOM_QSS = ConfigPath(ConfigType.GLOBAL, "appearance.custom_qss") + CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme") class TIMERTASK: ROOT = ConfigPath(ConfigType.TIMERTASK, "") diff --git a/src/managers/config/ConfigManager.py b/src/managers/config/ConfigManager.py index 58f0787..bc2b38e 100644 --- a/src/managers/config/ConfigManager.py +++ b/src/managers/config/ConfigManager.py @@ -58,7 +58,8 @@ class ConfigTemplate: "appearance": { "theme": "system", "style": "Fusion", - "custom_qss": "" + "custom_qss": "", + "custom_theme": "" } } case ConfigType.BULLETIN: diff --git a/src/managers/theme/ThemeManager.py b/src/managers/theme/ThemeManager.py new file mode 100644 index 0000000..dcf68da --- /dev/null +++ b/src/managers/theme/ThemeManager.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import os +import shutil +import tempfile +import threading +import zipfile + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication + +from managers.config.ConfigManager import instance as configInstance +from utils.ThemeUtils import ( + packTheme, + readThemeInfo, + unpackTheme +) + + +class ThemeManager: + """ + Theme manager class. + + Manages the themes storage directory, providing import, + list, remove, and apply operations for .altheme theme files. + + Args: + themes_dir (str): Path to the themes storage directory. + """ + + def __init__( + self, + themes_dir: str + ): + + self.__themes_dir = os.path.abspath(themes_dir) + self.__lock = threading.Lock() + self.__current_theme_name = "" + os.makedirs(self.__themes_dir, exist_ok=True) + + def themesDir( + self + ) -> str: + """ + Get the themes directory path. + + Returns: + str: The absolute path to the themes storage directory. + """ + + return self.__themes_dir + + def importTheme( + self, + source_path: str + ) -> str: + """ + Import a theme file into the themes directory. + + Supports .altheme (zip archive) and bare .qss files. + Bare .qss files are automatically wrapped into .altheme format. + For .altheme files, validates that theme.qss exists in the archive + and sanitises the theme name to prevent path traversal. + + Args: + source_path (str): Path to the .altheme or .qss file. + + Returns: + str: The imported theme name. + + Raises: + FileNotFoundError: If source_path does not exist. + ValueError: If the file type is unsupported or the .altheme is invalid. + """ + + if not os.path.isfile(source_path): + raise FileNotFoundError(source_path) + ext = os.path.splitext(source_path)[1].lower() + if ext == ".qss": + name = os.path.splitext(os.path.basename(source_path))[0] + info = { + "name": name, + "author": "未知", + "need_theme": "both", + "brief": "没有相关简介" + } + dest_path = os.path.join(self.__themes_dir, name + ".altheme") + packTheme(source_path, info, dest_path) + return name + elif ext == ".altheme": + with zipfile.ZipFile(source_path, "r") as zf: + if "theme.qss" not in zf.namelist(): + raise ValueError("无效的 .altheme: 缺少 theme.qss") + info = readThemeInfo(source_path) + name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) + safe_name = os.path.basename(name) + dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme") + shutil.copy2(source_path, dest_path) + return safe_name + else: + raise ValueError(f"不支持的文件类型: {ext}") + + def listThemes( + self + ) -> list: + """ + List all available themes in the themes directory. + + Scans the themes directory for .altheme files and reads + their info.json metadata. + + Returns: + list[dict]: A list of theme info dictionaries. + """ + + themes = [] + if not os.path.isdir(self.__themes_dir): + return themes + for filename in sorted(os.listdir(self.__themes_dir)): + if filename.endswith(".altheme"): + filepath = os.path.join(self.__themes_dir, filename) + try: + info = readThemeInfo(filepath) + themes.append(info) + except Exception: + pass + return themes + + def removeTheme( + self, + name: str + ): + """ + Remove a theme by name. + + If the removed theme is currently active, clears the QSS + stylesheet from the application. + + Args: + name (str): The theme name to remove. + """ + + filepath = os.path.join(self.__themes_dir, name + ".altheme") + with self.__lock: + if os.path.isfile(filepath): + os.remove(filepath) + if self.__current_theme_name == name: + self.__current_theme_name = "" + self._clearQss() + + def applyTheme( + self, + name: str + ): + """ + Apply a theme by name. + + Extracts the QSS from the .altheme file, applies it to + QApplication, and sets the Qt color scheme based on + the theme's need_theme metadata. + + Args: + name (str): The theme name to apply. + + Raises: + FileNotFoundError: If the theme .altheme file does not exist. + """ + + filepath = os.path.join(self.__themes_dir, name + ".altheme") + if not os.path.isfile(filepath): + raise FileNotFoundError(filepath) + info = readThemeInfo(filepath) + with tempfile.TemporaryDirectory() as tmpdir: + unpackTheme(filepath, tmpdir) + qss_path = os.path.join(tmpdir, "theme.qss") + if os.path.isfile(qss_path): + with open(qss_path, "r", encoding="utf-8") as fh: + qss = fh.read() + app = QApplication.instance() + if app: + app.setStyleSheet(qss) + app = QApplication.instance() + if app: + need_theme = info.get("need_theme", "both") + if need_theme == "dark": + app.styleHints().setColorScheme(Qt.ColorScheme.Dark) + elif need_theme == "light": + app.styleHints().setColorScheme(Qt.ColorScheme.Light) + with self.__lock: + self.__current_theme_name = name + + def currentThemeName( + self + ) -> str: + """ + Get the name of the currently active theme. + + Returns: + str: Current theme name, or empty string if none is active. + """ + + return self.__current_theme_name + + def _clearQss( + self + ): + """ + Clear the current QSS stylesheet from the application. + """ + + app = QApplication.instance() + if app: + app.setStyleSheet("") + +# ThemeManager singleton instance. +_theme_manager_instance = None + +# Singleton instance lock. +_instance_lock = threading.Lock() + + +def instance( + themes_dir: str = "" +) -> ThemeManager: + """ + Get the ThemeManager singleton instance. + + On first call, initialises the ThemeManager with the themes + directory derived from ConfigManager's config directory. + + Args: + themes_dir (str): Optional themes directory path. + + Returns: + ThemeManager: The singleton ThemeManager instance. + """ + + global _theme_manager_instance + with _instance_lock: + if _theme_manager_instance is None: + if not themes_dir: + cfg = configInstance() + themes_dir = os.path.join(cfg.configDir(), "themes") + _theme_manager_instance = ThemeManager(themes_dir) + return _theme_manager_instance diff --git a/src/managers/theme/__init__.py b/src/managers/theme/__init__.py new file mode 100644 index 0000000..5cb1dac --- /dev/null +++ b/src/managers/theme/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" diff --git a/src/utils/ThemeUtils.py b/src/utils/ThemeUtils.py new file mode 100644 index 0000000..5946ef6 --- /dev/null +++ b/src/utils/ThemeUtils.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import json +import os +import zipfile + + +def packTheme( + qss_path: str, + info: dict, + output_path: str +): + """ + Pack a .qss file and info dict into a .altheme file. + + The .altheme file is a zip archive containing info.json and theme.qss. + + Args: + qss_path (str): Path to the .qss stylesheet file. + info (dict): Theme metadata dict with keys name, author, need_theme, brief. + output_path (str): Destination path for the .altheme file. + + Raises: + FileNotFoundError: If qss_path does not exist. + """ + + if not os.path.isfile(qss_path): + raise FileNotFoundError(qss_path) + with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4)) + zf.write(qss_path, "theme.qss") + + +def unpackTheme( + altheme_path: str, + output_dir: str +): + """ + Extract a .altheme file to a directory. + + Performs Zip Slip validation before extraction. + + Args: + altheme_path (str): Path to the .altheme file. + output_dir (str): Directory to extract contents into. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If a zip entry contains an unsafe path. + """ + + if not os.path.isfile(altheme_path): + raise FileNotFoundError(altheme_path) + os.makedirs(output_dir, exist_ok=True) + with zipfile.ZipFile(altheme_path, "r") as zf: + for name in zf.namelist(): + if name.startswith("/") or ".." in name: + raise ValueError(f"不安全的 .altheme 入口: {name}") + zf.extractall(output_dir) + + +def readThemeInfo( + altheme_path: str +) -> dict: + """ + Read only the info.json metadata from a .altheme file. + + Args: + altheme_path (str): Path to the .altheme file. + + Returns: + dict: The theme metadata dictionary. + + Raises: + FileNotFoundError: If altheme_path does not exist. + ValueError: If the .altheme does not contain info.json. + """ + + if not os.path.isfile(altheme_path): + raise FileNotFoundError(altheme_path) + with zipfile.ZipFile(altheme_path, "r") as zf: + if "info.json" not in zf.namelist(): + raise ValueError("无效的 .altheme: 缺少 info.json") + with zf.open("info.json") as fh: + return json.loads(fh.read().decode("utf-8")) + + +def wrapQssToAtheme( + qss_path: str, + output_path: str, + current_theme: str +): + """ + Wrap a bare .qss file into a .altheme file with auto-generated metadata. + + The generated info.json uses the filename as the theme name + and sets default values for author and brief. + + Args: + qss_path (str): Path to the bare .qss stylesheet file. + output_path (str): Destination path for the .altheme file. + current_theme (str): The need_theme value to embed in metadata + ("light", "dark", or "both"). + + Raises: + FileNotFoundError: If qss_path does not exist. + """ + + filename = os.path.splitext(os.path.basename(qss_path))[0] + info = { + "name": filename, + "author": "未知", + "need_theme": current_theme, + "brief": "没有相关简介" + } + packTheme(qss_path, info, output_path)