360 lines
11 KiB
Python
360 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright (c) 2026 KenanZhu.
|
|
All rights reserved.
|
|
|
|
This software is provided "as is", without any warranty of any kind.
|
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
See the LICENSE file for details.
|
|
"""
|
|
import os
|
|
import shutil
|
|
import threading
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import (
|
|
QApplication,
|
|
QStyleFactory
|
|
)
|
|
|
|
from interfaces.ConfigProvider import CfgKey
|
|
from managers.config.ConfigManager import instance as configInstance
|
|
from managers.log.LogManager import instance as logInstance
|
|
from utils.ThemeUtils import (
|
|
readThemeInfo,
|
|
readThemeQss,
|
|
validateTheme,
|
|
wrapQssToAtheme
|
|
)
|
|
|
|
|
|
# we use Fusion as the default active style name
|
|
# because it's cross-platform.
|
|
_active_style_name = "Fusion"
|
|
|
|
|
|
def setActiveStyle(
|
|
style_name: str
|
|
):
|
|
|
|
global _active_style_name
|
|
_active_style_name = style_name
|
|
|
|
def getActiveStyle(
|
|
) -> str:
|
|
|
|
return _active_style_name
|
|
|
|
|
|
class ThemeManager:
|
|
"""
|
|
Theme manager class.
|
|
|
|
Manages the themes storage directory, providing import,
|
|
list, remove, and apply operations for .altheme theme files.
|
|
|
|
Args:
|
|
themes_dir (str): Path to the themes storage directory.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
themes_dir: str
|
|
):
|
|
|
|
self.__themes_dir = os.path.abspath(themes_dir)
|
|
self.__current_theme_name = ""
|
|
self.__lock = threading.Lock()
|
|
|
|
# directory must exist
|
|
os.makedirs(self.__themes_dir, exist_ok=True)
|
|
|
|
@staticmethod
|
|
def _colorSchemeFor(
|
|
theme: str
|
|
) -> Qt.ColorScheme:
|
|
"""
|
|
Map a theme identifier to the corresponding Qt color scheme.
|
|
"""
|
|
|
|
if theme == "dark":
|
|
return Qt.ColorScheme.Dark
|
|
elif theme == "light":
|
|
return Qt.ColorScheme.Light
|
|
else:
|
|
return Qt.ColorScheme.Unknown
|
|
|
|
def _deleteThemeFile(
|
|
self,
|
|
name: str
|
|
):
|
|
"""
|
|
Delete a theme file in the themes storage directory.
|
|
|
|
The caller must hold self.__lock before invoking this method.
|
|
|
|
**This method ONLY deletes the file**.
|
|
"""
|
|
|
|
filepath = os.path.join(self.__themes_dir, name + ".altheme")
|
|
if os.path.isfile(filepath):
|
|
os.remove(filepath)
|
|
|
|
def _resolveDestPath(
|
|
self,
|
|
theme_name: str,
|
|
author: str
|
|
) -> str:
|
|
"""
|
|
Resolve the destination path for an imported theme.
|
|
|
|
If the default {name}.altheme path does not exist, use it directly.
|
|
If it exists and has a different author, use {name}_{author}.altheme.
|
|
If it exists and has the same author, raise ValueError.
|
|
|
|
Args:
|
|
theme_name (str): Sanitised theme name.
|
|
author (str): Theme author string.
|
|
|
|
Returns:
|
|
str: The resolved destination file path.
|
|
|
|
Raises:
|
|
ValueError: If a theme with the same name and author already exists.
|
|
"""
|
|
|
|
default_path = os.path.join(self.__themes_dir, theme_name + ".altheme")
|
|
if not os.path.exists(default_path):
|
|
return default_path
|
|
try:
|
|
existing_info = validateTheme(default_path)
|
|
existing_author = existing_info.get("author", "")
|
|
except Exception:
|
|
self._deleteThemeFile(theme_name) # caller holds the lock
|
|
raise ValueError(
|
|
f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件"
|
|
)
|
|
if existing_author == author:
|
|
raise ValueError(
|
|
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
|
|
)
|
|
safe_author = os.path.basename(author) if author else "未知作者"
|
|
alt_path = os.path.join(
|
|
self.__themes_dir, f"{theme_name}_{safe_author}.altheme"
|
|
)
|
|
if os.path.exists(alt_path):
|
|
raise ValueError(
|
|
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
|
|
)
|
|
return alt_path
|
|
|
|
def importTheme(
|
|
self,
|
|
source_path: str
|
|
) -> str:
|
|
"""
|
|
Import a theme file into the themes directory.
|
|
|
|
Supports .altheme (zip archive) and bare .qss files.
|
|
Bare .qss files are automatically wrapped into .altheme format.
|
|
For .altheme files, validates that theme.qss exists in the archive
|
|
and sanitises the theme name to prevent path traversal.
|
|
|
|
Args:
|
|
source_path (str): Path to the .altheme or .qss file.
|
|
|
|
Returns:
|
|
str: The imported theme name.
|
|
|
|
Raises:
|
|
FileNotFoundError: If source_path does not exist.
|
|
ValueError: If the file type is unsupported or the .altheme is invalid.
|
|
"""
|
|
|
|
if not os.path.isfile(source_path):
|
|
raise FileNotFoundError(source_path)
|
|
base_name, ext = os.path.splitext(os.path.basename(source_path))
|
|
ext = ext.lower()
|
|
with self.__lock:
|
|
if ext == ".qss":
|
|
dest_path = self._resolveDestPath(base_name, "未知作者")
|
|
wrapQssToAtheme(source_path, dest_path, "both")
|
|
return os.path.splitext(os.path.basename(dest_path))[0]
|
|
elif ext == ".altheme":
|
|
info = validateTheme(source_path)
|
|
name = info.get("name", base_name)
|
|
safe_name = os.path.basename(name)
|
|
new_author = info.get("author", "")
|
|
dest_path = self._resolveDestPath(safe_name, new_author)
|
|
shutil.copy2(source_path, dest_path)
|
|
return os.path.splitext(os.path.basename(dest_path))[0]
|
|
else:
|
|
raise ValueError(f"不支持的文件类型: {ext}")
|
|
|
|
def listThemes(
|
|
self
|
|
) -> list:
|
|
"""
|
|
List all available themes in the themes directory.
|
|
|
|
Scans the themes directory for .altheme files and reads
|
|
their info.json metadata.
|
|
|
|
Returns:
|
|
list[dict]: A list of theme info dictionaries.
|
|
"""
|
|
|
|
themes = []
|
|
seen_keys = set()
|
|
if not os.path.isdir(self.__themes_dir):
|
|
return themes
|
|
for filename in sorted(os.listdir(self.__themes_dir)):
|
|
if filename.endswith(".altheme"):
|
|
filepath = os.path.join(self.__themes_dir, filename)
|
|
try:
|
|
info = validateTheme(filepath)
|
|
name = info.get("name", "")
|
|
author = info.get("author", "")
|
|
key = (name, author)
|
|
if key in seen_keys:
|
|
logInstance().getLogger("ThemeManager").warning(
|
|
f"主题名称 '{name}' (作者 '{author}') 重复 (文件 '{filename}') 已跳过"
|
|
)
|
|
continue
|
|
seen_keys.add(key)
|
|
info["file"] = os.path.splitext(filename)[0]
|
|
themes.append(info)
|
|
except Exception as e:
|
|
logInstance().getLogger("ThemeManager").warning(
|
|
f"无法读取主题文件 '{filename}',已跳过: {e}"
|
|
)
|
|
else:
|
|
logInstance().getLogger("ThemeManager").warning(
|
|
f"未知文件类型 '{filename}',已跳过"
|
|
)
|
|
return themes
|
|
|
|
def removeTheme(
|
|
self,
|
|
name: str
|
|
):
|
|
"""
|
|
Remove a theme by name.
|
|
|
|
If the removed theme is currently active, clears the QSS
|
|
stylesheet from the application and reverts to the saved
|
|
colour scheme.
|
|
|
|
Args:
|
|
name (str): The theme name to remove.
|
|
"""
|
|
|
|
with self.__lock:
|
|
self._deleteThemeFile(name)
|
|
if self.__current_theme_name == name:
|
|
self.__current_theme_name = ""
|
|
saved_theme = configInstance().get(
|
|
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
|
|
)
|
|
self.clearTheme(saved_theme)
|
|
|
|
def applyTheme(
|
|
self,
|
|
name: str
|
|
):
|
|
"""
|
|
Apply a theme by name.
|
|
|
|
Extracts the QSS from the .altheme file, applies it to
|
|
QApplication, and sets the Qt color scheme based on
|
|
the theme's need_theme metadata.
|
|
|
|
Args:
|
|
name (str): The theme name to apply.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the theme .altheme file does not exist.
|
|
"""
|
|
|
|
filepath = os.path.join(self.__themes_dir, name + ".altheme")
|
|
if not os.path.isfile(filepath):
|
|
raise FileNotFoundError(filepath)
|
|
with self.__lock:
|
|
info = readThemeInfo(filepath)
|
|
qss = readThemeQss(filepath)
|
|
app = QApplication.instance()
|
|
if app:
|
|
app.setStyleSheet(qss)
|
|
need_theme = info.get("need_theme", "both")
|
|
app.styleHints().setColorScheme(
|
|
ThemeManager._colorSchemeFor(need_theme)
|
|
)
|
|
app.setStyle(QStyleFactory.create(_active_style_name))
|
|
self.__current_theme_name = name
|
|
|
|
def clearTheme(
|
|
self,
|
|
theme: str
|
|
):
|
|
"""
|
|
Clear the current QSS stylesheet and apply the given color scheme.
|
|
|
|
Args:
|
|
theme (str): The color scheme to apply after clearing
|
|
("light", "dark", or "system").
|
|
"""
|
|
|
|
app = QApplication.instance()
|
|
if not app:
|
|
return
|
|
app.setStyleSheet("")
|
|
app.styleHints().setColorScheme(
|
|
ThemeManager._colorSchemeFor(theme)
|
|
)
|
|
app.setStyle(QStyleFactory.create(_active_style_name))
|
|
|
|
def themesDir(
|
|
self
|
|
) -> str:
|
|
"""
|
|
Get the themes directory path.
|
|
|
|
Returns:
|
|
str: The absolute path to the themes storage directory.
|
|
"""
|
|
|
|
return self.__themes_dir
|
|
|
|
|
|
# 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
|