import importlib.util import os from portprotonqt.logger import get_logger from PySide6.QtSvg import QSvgRenderer from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo logger = get_logger(__name__) # Папка, где располагаются все дополнительные темы xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) THEMES_DIRS = [ os.path.join(xdg_data_home, "PortProtonQT", "themes"), os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") ] def list_themes(): """ Возвращает список доступных тем (названий папок) из каталогов THEMES_DIRS. """ themes = [] for themes_dir in THEMES_DIRS: if os.path.exists(themes_dir): for entry in os.listdir(themes_dir): theme_path = os.path.join(themes_dir, entry) if os.path.isdir(theme_path) and os.path.exists(os.path.join(theme_path, "styles.py")): themes.append(entry) return themes def load_theme_screenshots(theme_name): """ Загружает все скриншоты из папки "screenshots", расположенной в папке темы. Возвращает список кортежей (pixmap, filename). Если папка отсутствует или пуста, возвращается пустой список. """ screenshots = [] for themes_dir in THEMES_DIRS: theme_folder = os.path.join(themes_dir, theme_name) screenshots_folder = os.path.join(theme_folder, "images", "screenshots") if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder): for file in os.listdir(screenshots_folder): screenshot_path = os.path.join(screenshots_folder, file) if os.path.isfile(screenshot_path): pixmap = QPixmap(screenshot_path) if not pixmap.isNull(): screenshots.append((pixmap, file)) return screenshots def load_theme_fonts(theme_name): """ Загружает все шрифты выбранной темы. :param theme_name: Имя темы. """ QFontDatabase.removeAllApplicationFonts() fonts_folder = None if theme_name == "standart": base_dir = os.path.dirname(os.path.abspath(__file__)) fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts") else: for themes_dir in THEMES_DIRS: theme_folder = os.path.join(themes_dir, theme_name) possible_fonts_folder = os.path.join(theme_folder, "fonts") if os.path.exists(possible_fonts_folder): fonts_folder = possible_fonts_folder break if not fonts_folder or not os.path.exists(fonts_folder): logger.error(f"Папка fonts не найдена для темы '{theme_name}'") return for filename in os.listdir(fonts_folder): if filename.lower().endswith((".ttf", ".otf")): font_path = os.path.join(fonts_folder, filename) font_id = QFontDatabase.addApplicationFont(font_path) if font_id != -1: families = QFontDatabase.applicationFontFamilies(font_id) logger.info(f"Шрифт {filename} успешно загружен: {families}") else: logger.error(f"Ошибка загрузки шрифта: {filename}") def load_logo(): logo_path = None base_dir = os.path.dirname(os.path.abspath(__file__)) logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg") file_extension = os.path.splitext(logo_path)[1].lower() if file_extension == ".svg": renderer = QSvgRenderer(logo_path) if not renderer.isValid(): logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}") return None pixmap = QPixmap(128, 128) pixmap.fill(QColor(0, 0, 0, 0)) painter = QPainter(pixmap) renderer.render(painter) painter.end() return pixmap class ThemeWrapper: """ Обёртка для кастомной темы с поддержкой метаинформации. При обращении к атрибуту сначала ищется его наличие в кастомной теме, если атрибут отсутствует, значение берётся из стандартного модуля стилей. """ def __init__(self, custom_theme, metainfo=None): self.custom_theme = custom_theme self.metainfo = metainfo or {} self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) def __getattr__(self, name): if hasattr(self.custom_theme, name): return getattr(self.custom_theme, name) import portprotonqt.themes.standart.styles as default_styles return getattr(default_styles, name) def load_theme(theme_name): """ Динамически загружает модуль стилей выбранной темы и метаинформацию. Если выбрана стандартная тема, импортируется оригинальный styles.py. Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты. """ if theme_name == "standart": import portprotonqt.themes.standart.styles as default_styles return default_styles for themes_dir in THEMES_DIRS: theme_folder = os.path.join(themes_dir, theme_name) styles_file = os.path.join(theme_folder, "styles.py") if os.path.exists(styles_file): spec = importlib.util.spec_from_file_location("theme_styles", styles_file) if spec is None or spec.loader is None: continue custom_theme = importlib.util.module_from_spec(spec) spec.loader.exec_module(custom_theme) meta = load_theme_metainfo(theme_name) wrapper = ThemeWrapper(custom_theme, metainfo=meta) wrapper.screenshots = load_theme_screenshots(theme_name) return wrapper raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'") class ThemeManager: """ Класс для управления темами приложения. Позволяет получить список доступных тем, загрузить и применить выбранную тему. """ def __init__(self): self.current_theme_name = None self.current_theme_module = None def get_available_themes(self): """Возвращает список доступных тем.""" return list_themes() def get_theme_logo(self): """Возвращает логотип для текущей или указанной темы.""" return load_logo() def apply_theme(self, theme_name): """ Применяет выбранную тему: загружает модуль стилей, шрифты и логотип. Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации. :param theme_name: Имя темы. :return: Загруженный модуль темы (или обёртка). """ theme_module = load_theme(theme_name) load_theme_fonts(theme_name) self.current_theme_name = theme_name self.current_theme_module = theme_module save_theme_to_config(theme_name) logger.info(f"Тема '{theme_name}' успешно применена") return theme_module def get_icon(self, icon_name, theme_name=None, as_path=False): """ Возвращает QIcon из папки icons текущей темы, а если файл не найден, то из стандартной темы. Если as_path=True, возвращает путь к иконке вместо QIcon. """ icon_path = None theme_name = theme_name or self.current_theme_name supported_extensions = ['.svg', '.png', '.jpg', '.jpeg'] has_extension = any(icon_name.lower().endswith(ext) for ext in supported_extensions) base_name = icon_name if has_extension else icon_name # Поиск иконки в папке текущей темы for themes_dir in THEMES_DIRS: theme_folder = os.path.join(str(themes_dir), str(theme_name)) icons_folder = os.path.join(theme_folder, "images", "icons") # Если передано имя с расширением, проверяем только этот файл if has_extension: candidate = os.path.join(icons_folder, str(base_name)) if os.path.exists(candidate): icon_path = candidate break else: # Проверяем все поддерживаемые расширения for ext in supported_extensions: candidate = os.path.join(icons_folder, str(base_name) + str(ext)) if os.path.exists(candidate): icon_path = candidate break if icon_path: break # Если не нашли – используем стандартную тему if not icon_path: base_dir = os.path.dirname(os.path.abspath(__file__)) standard_icons_folder = os.path.join(base_dir, "themes", "standart", "images", "icons") # Аналогично проверяем в стандартной теме if has_extension: icon_path = os.path.join(standard_icons_folder, base_name) if not os.path.exists(icon_path): icon_path = None else: for ext in supported_extensions: candidate = os.path.join(standard_icons_folder, base_name + ext) if os.path.exists(candidate): icon_path = candidate break # Если иконка всё равно не найдена if not icon_path or not os.path.exists(icon_path): logger.error(f"Предупреждение: иконка '{icon_name}' не найдена") return QIcon() if not as_path else None if as_path: return icon_path return QIcon(icon_path) def get_theme_image(self, image_name, theme_name=None): """ Возвращает путь к изображению из папки текущей темы. Если не найдено, проверяет стандартную тему. Принимает название иконки без расширения и находит соответствующий файл с поддерживаемым расширением (.svg, .png, .jpg и др.). """ image_path = None theme_name = theme_name or self.current_theme_name supported_extensions = ['.svg', '.png', '.jpg', '.jpeg'] has_extension = any(image_name.lower().endswith(ext) for ext in supported_extensions) base_name = image_name if has_extension else image_name # Check theme-specific images for themes_dir in THEMES_DIRS: theme_folder = os.path.join(str(themes_dir), str(theme_name)) images_folder = os.path.join(theme_folder, "images") if has_extension: candidate = os.path.join(images_folder, str(base_name)) if os.path.exists(candidate): image_path = candidate break else: for ext in supported_extensions: candidate = os.path.join(images_folder, str(base_name) + str(ext)) if os.path.exists(candidate): image_path = candidate break if image_path: break # Check standard theme if not image_path: base_dir = os.path.dirname(os.path.abspath(__file__)) standard_images_folder = os.path.join(base_dir, "themes", "standart", "images") if has_extension: image_path = os.path.join(standard_images_folder, base_name) if not os.path.exists(image_path): image_path = None else: for ext in supported_extensions: candidate = os.path.join(standard_images_folder, base_name + ext) if os.path.exists(candidate): image_path = candidate break return image_path