Move repo from git to gitea
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
0
portprotonqt/__init__.py
Normal file
50
portprotonqt/app.py
Normal file
@ -0,0 +1,50 @@
|
||||
import sys
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.tray import SystemTray
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.1"
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||
app.setDesktopFileName(__app_id__)
|
||||
app.setApplicationName(__app_name__)
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
system_locale = QLocale.system()
|
||||
qt_translator = QTranslator()
|
||||
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||||
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
|
||||
app.installTranslator(qt_translator)
|
||||
else:
|
||||
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
||||
|
||||
window = MainWindow()
|
||||
current_theme_name = read_theme_from_config()
|
||||
tray = SystemTray(app, current_theme_name)
|
||||
tray.show_action.triggered.connect(window.show)
|
||||
tray.hide_action.triggered.connect(window.hide)
|
||||
|
||||
def recreate_tray():
|
||||
nonlocal tray
|
||||
tray.hide_tray()
|
||||
current_theme = read_theme_from_config()
|
||||
tray = SystemTray(app, current_theme)
|
||||
tray.show_action.triggered.connect(window.show)
|
||||
tray.hide_action.triggered.connect(window.hide)
|
||||
|
||||
window.settings_saved.connect(recreate_tray)
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
484
portprotonqt/config_utils.py
Normal file
@ -0,0 +1,484 @@
|
||||
import os
|
||||
import configparser
|
||||
import shutil
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_portproton_location = None
|
||||
|
||||
# Пути к конфигурационным файлам
|
||||
CONFIG_FILE = os.path.join(
|
||||
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
|
||||
"PortProtonQT.conf"
|
||||
)
|
||||
|
||||
PORTPROTON_CONFIG_FILE = os.path.join(
|
||||
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
|
||||
"PortProton.conf"
|
||||
)
|
||||
|
||||
# Пути к папкам с темами
|
||||
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 read_config():
|
||||
"""
|
||||
Читает конфигурационный файл и возвращает словарь параметров.
|
||||
Пример строки в конфиге (без секций):
|
||||
detail_level = detailed
|
||||
"""
|
||||
config_dict = {}
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
with open(CONFIG_FILE, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, sep, value = line.partition("=")
|
||||
if sep:
|
||||
config_dict[key.strip()] = value.strip()
|
||||
return config_dict
|
||||
|
||||
def read_theme_from_config():
|
||||
"""
|
||||
Читает из конфигурационного файла тему из секции [Appearance].
|
||||
Если параметр не задан, возвращает "standart".
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
return "standart"
|
||||
return cp.get("Appearance", "theme", fallback="standart")
|
||||
|
||||
def save_theme_to_config(theme_name):
|
||||
"""
|
||||
Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
if "Appearance" not in cp:
|
||||
cp["Appearance"] = {}
|
||||
cp["Appearance"]["theme"] = theme_name
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_time_config():
|
||||
"""
|
||||
Читает настройки времени из секции [Time] конфигурационного файла.
|
||||
Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
save_time_config("detailed")
|
||||
return "detailed"
|
||||
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
|
||||
save_time_config("detailed")
|
||||
return "detailed"
|
||||
return cp.get("Time", "detail_level", fallback="detailed").lower()
|
||||
return "detailed"
|
||||
|
||||
def save_time_config(detail_level):
|
||||
"""
|
||||
Сохраняет настройку уровня детализации времени в секции [Time].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
if "Time" not in cp:
|
||||
cp["Time"] = {}
|
||||
cp["Time"]["detail_level"] = detail_level
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_file_content(file_path):
|
||||
"""
|
||||
Читает содержимое файла и возвращает его как строку.
|
||||
"""
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
|
||||
def get_portproton_location():
|
||||
"""
|
||||
Возвращает путь к директории PortProton.
|
||||
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
|
||||
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
|
||||
используется директория по умолчанию.
|
||||
"""
|
||||
global _portproton_location
|
||||
if _portproton_location is not None:
|
||||
return _portproton_location
|
||||
|
||||
# Попытка чтения пути из конфигурационного файла
|
||||
if os.path.isfile(PORTPROTON_CONFIG_FILE):
|
||||
try:
|
||||
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
|
||||
if location and os.path.isdir(location):
|
||||
_portproton_location = location
|
||||
logger.info(f"Путь PortProton из конфигурации: {location}")
|
||||
return _portproton_location
|
||||
logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
|
||||
|
||||
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_dir):
|
||||
_portproton_location = default_dir
|
||||
logger.info(f"Используется директория flatpak PortProton: {default_dir}")
|
||||
return _portproton_location
|
||||
|
||||
logger.warning("Конфигурация и директория flatpak PortProton не найдены")
|
||||
return None
|
||||
|
||||
def parse_desktop_entry(file_path):
|
||||
"""
|
||||
Читает и парсит .desktop файл с помощью configparser.
|
||||
Если секция [Desktop Entry] отсутствует, возвращается None.
|
||||
"""
|
||||
cp = configparser.ConfigParser(interpolation=None)
|
||||
cp.read(file_path, encoding="utf-8")
|
||||
if "Desktop Entry" not in cp:
|
||||
return None
|
||||
return cp["Desktop Entry"]
|
||||
|
||||
def load_theme_metainfo(theme_name):
|
||||
"""
|
||||
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
|
||||
Ожидаемые поля: author, author_link, description, name.
|
||||
"""
|
||||
meta = {}
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||
if os.path.exists(metainfo_file):
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
if "Metainfo" in cp:
|
||||
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
|
||||
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
|
||||
meta["description"] = cp.get("Metainfo", "description", fallback="")
|
||||
meta["name"] = cp.get("Metainfo", "name", fallback=theme_name)
|
||||
break
|
||||
return meta
|
||||
|
||||
def read_card_size():
|
||||
"""
|
||||
Читает размер карточек (ширину) из секции [Cards],
|
||||
Если параметр не задан, возвращает 250.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
save_card_size(250)
|
||||
return 250
|
||||
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
|
||||
save_card_size(250)
|
||||
return 250
|
||||
return cp.getint("Cards", "card_width", fallback=250)
|
||||
return 250
|
||||
|
||||
def save_card_size(card_width):
|
||||
"""
|
||||
Сохраняет размер карточек (ширину) в секцию [Cards].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
if "Cards" not in cp:
|
||||
cp["Cards"] = {}
|
||||
cp["Cards"]["card_width"] = str(card_width)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_sort_method():
|
||||
"""
|
||||
Читает метод сортировки из секции [Games].
|
||||
Если параметр не задан, возвращает last_launch.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
save_sort_method("last_launch")
|
||||
return "last_launch"
|
||||
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
|
||||
save_sort_method("last_launch")
|
||||
return "last_launch"
|
||||
return cp.get("Games", "sort_method", fallback="last_launch").lower()
|
||||
return "last_launch"
|
||||
|
||||
def save_sort_method(sort_method):
|
||||
"""
|
||||
Сохраняет метод сортировки в секцию [Games].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
if "Games" not in cp:
|
||||
cp["Games"] = {}
|
||||
cp["Games"]["sort_method"] = sort_method
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_display_filter():
|
||||
"""
|
||||
Читает параметр display_filter из секции [Games].
|
||||
Если параметр отсутствует, сохраняет и возвращает значение "all".
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
save_display_filter("all")
|
||||
return "all"
|
||||
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
|
||||
save_display_filter("all")
|
||||
return "all"
|
||||
return cp.get("Games", "display_filter", fallback="all").lower()
|
||||
return "all"
|
||||
|
||||
def save_display_filter(filter_value):
|
||||
"""
|
||||
Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
if "Games" not in cp:
|
||||
cp["Games"] = {}
|
||||
cp["Games"]["display_filter"] = filter_value
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_favorites():
|
||||
"""
|
||||
Читает список избранных игр из секции [Favorites] конфигурационного файла.
|
||||
Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
|
||||
Если секция или параметр отсутствуют, возвращает пустой список.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
return []
|
||||
if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
|
||||
favs = cp.get("Favorites", "games", fallback="").strip()
|
||||
# Если строка начинается и заканчивается кавычками, удаляем их
|
||||
if favs.startswith('"') and favs.endswith('"'):
|
||||
favs = favs[1:-1]
|
||||
return [s.strip() for s in favs.split(",") if s.strip()]
|
||||
return []
|
||||
|
||||
def save_favorites(favorites):
|
||||
"""
|
||||
Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
|
||||
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
if "Favorites" not in cp:
|
||||
cp["Favorites"] = {}
|
||||
fav_str = ", ".join(favorites)
|
||||
cp["Favorites"]["games"] = f'"{fav_str}"'
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def ensure_default_proxy_config():
|
||||
"""
|
||||
Проверяет наличие секции [Proxy] в конфигурационном файле.
|
||||
Если секция отсутствует, создаёт её с пустыми значениями.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
return
|
||||
if not cp.has_section("Proxy"):
|
||||
cp.add_section("Proxy")
|
||||
cp["Proxy"]["proxy_url"] = ""
|
||||
cp["Proxy"]["proxy_user"] = ""
|
||||
cp["Proxy"]["proxy_password"] = ""
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
|
||||
def read_proxy_config():
|
||||
"""
|
||||
Читает настройки прокси из секции [Proxy] конфигурационного файла.
|
||||
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
|
||||
"""
|
||||
ensure_default_proxy_config()
|
||||
cp = configparser.ConfigParser()
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
return {}
|
||||
|
||||
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
|
||||
if proxy_url:
|
||||
# Если указаны логин и пароль, добавляем их к URL
|
||||
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
|
||||
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
|
||||
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
|
||||
protocol, rest = proxy_url.split("://", 1)
|
||||
proxy_url = f"{protocol}://{proxy_user}:{proxy_password}@{rest}"
|
||||
return {"http": proxy_url, "https": proxy_url}
|
||||
return {}
|
||||
|
||||
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
|
||||
"""
|
||||
Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
|
||||
Если секция отсутствует, создаёт её.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
if "Proxy" not in cp:
|
||||
cp["Proxy"] = {}
|
||||
cp["Proxy"]["proxy_url"] = proxy_url
|
||||
cp["Proxy"]["proxy_user"] = proxy_user
|
||||
cp["Proxy"]["proxy_password"] = proxy_password
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_fullscreen_config():
|
||||
"""
|
||||
Читает настройку полноэкранного режима приложения из секции [Display].
|
||||
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
save_fullscreen_config(False)
|
||||
return False
|
||||
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
|
||||
save_fullscreen_config(False)
|
||||
return False
|
||||
return cp.getboolean("Display", "fullscreen", fallback=False)
|
||||
return False
|
||||
|
||||
def save_fullscreen_config(fullscreen):
|
||||
"""
|
||||
Сохраняет настройку полноэкранного режима приложения в секцию [Display].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["fullscreen"] = str(fullscreen)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
|
||||
|
||||
def read_window_geometry() -> tuple[int, int]:
|
||||
"""
|
||||
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
|
||||
Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
return (0, 0)
|
||||
if cp.has_section("MainWindow"):
|
||||
width = cp.getint("MainWindow", "width", fallback=0)
|
||||
height = cp.getint("MainWindow", "height", fallback=0)
|
||||
return (width, height)
|
||||
return (0, 0)
|
||||
|
||||
def save_window_geometry(width: int, height: int):
|
||||
"""
|
||||
Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
if "MainWindow" not in cp:
|
||||
cp["MainWindow"] = {}
|
||||
cp["MainWindow"]["width"] = str(width)
|
||||
cp["MainWindow"]["height"] = str(height)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def reset_config():
|
||||
"""
|
||||
Сбрасывает конфигурационный файл, удаляя его.
|
||||
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
|
||||
"""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
os.remove(CONFIG_FILE)
|
||||
logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при удалении конфигурационного файла: %s", e)
|
||||
|
||||
def clear_cache():
|
||||
"""
|
||||
Очищает кэш PortProtonQT, удаляя папку кэша.
|
||||
"""
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
cache_dir = os.path.join(xdg_cache_home, "PortProtonQT")
|
||||
if os.path.exists(cache_dir):
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
logger.info("Кэш PortProtonQT удалён: %s", cache_dir)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при удалении кэша: %s", e)
|
467
portprotonqt/context_menu_manager.py
Normal file
@ -0,0 +1,467 @@
|
||||
import os
|
||||
import shlex
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
|
||||
from PySide6.QtCore import QUrl, QPoint
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from portprotonqt.config_utils import parse_desktop_entry
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||
|
||||
class ContextMenuManager:
|
||||
"""Manages context menu actions for game management in PortProtonQT."""
|
||||
|
||||
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
|
||||
"""
|
||||
Initialize the ContextMenuManager.
|
||||
|
||||
Args:
|
||||
parent: The parent widget (MainWindow instance).
|
||||
portproton_location: Path to the PortProton directory.
|
||||
theme: The current theme object.
|
||||
load_games_callback: Callback to reload games list.
|
||||
update_game_grid_callback: Callback to update the game grid UI.
|
||||
"""
|
||||
self.parent = parent
|
||||
self.portproton_location = portproton_location
|
||||
self.theme = theme
|
||||
self.load_games = load_games_callback
|
||||
self.update_game_grid = update_game_grid_callback
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
Args:
|
||||
game_card: The GameCard instance requesting the context menu.
|
||||
pos: The position (in widget coordinates) where the menu should appear.
|
||||
"""
|
||||
|
||||
menu = QMenu(self.parent)
|
||||
if game_card.steam_game != "true":
|
||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||
if os.path.exists(desktop_path):
|
||||
remove_action = menu.addAction(_("Remove from Desktop"))
|
||||
remove_action.triggered.connect(lambda: self.remove_from_desktop(game_card.name))
|
||||
else:
|
||||
add_action = menu.addAction(_("Add to Desktop"))
|
||||
add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line))
|
||||
|
||||
edit_action = menu.addAction(_("Edit Shortcut"))
|
||||
edit_action.triggered.connect(lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path))
|
||||
|
||||
delete_action = menu.addAction(_("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
|
||||
open_folder_action = menu.addAction(_("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(lambda: self.open_game_folder(game_card.name, game_card.exec_line))
|
||||
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||
if os.path.exists(desktop_path):
|
||||
remove_action = menu.addAction(_("Remove from Menu"))
|
||||
remove_action.triggered.connect(lambda: self.remove_from_menu(game_card.name))
|
||||
else:
|
||||
add_action = menu.addAction(_("Add to Menu"))
|
||||
add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line))
|
||||
|
||||
# Add Steam-related actions
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
if is_in_steam:
|
||||
remove_steam_action = menu.addAction(_("Remove from Steam"))
|
||||
remove_steam_action.triggered.connect(lambda: self.remove_from_steam(game_card.name, game_card.exec_line))
|
||||
else:
|
||||
add_steam_action = menu.addAction(_("Add to Steam"))
|
||||
add_steam_action.triggered.connect(lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path))
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
|
||||
def _check_portproton(self):
|
||||
"""Check if PortProton is available."""
|
||||
if self.portproton_location is None:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found."))
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_desktop_path(self, game_name):
|
||||
"""Construct the .desktop file path, trying both original and sanitized game names."""
|
||||
desktop_path = os.path.join(self.portproton_location, f"{game_name}.desktop")
|
||||
if not os.path.exists(desktop_path):
|
||||
sanitized_name = game_name.replace("/", "_").replace(":", "_").replace(" ", "_")
|
||||
desktop_path = os.path.join(self.portproton_location, f"{sanitized_name}.desktop")
|
||||
return desktop_path
|
||||
|
||||
def _get_exec_line(self, game_name, exec_line):
|
||||
"""Retrieve and validate exec_line from .desktop file if necessary."""
|
||||
if exec_line and exec_line.strip() != "full":
|
||||
return exec_line
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if os.path.exists(desktop_path):
|
||||
try:
|
||||
entry = parse_desktop_entry(desktop_path)
|
||||
if entry:
|
||||
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
||||
if not exec_line:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("No executable command found in .desktop for game: {0}").format(game_name)
|
||||
)
|
||||
return None
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Failed to parse .desktop file for game: {0}").format(game_name)
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Error reading .desktop file: {0}").format(e)
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# Fallback: Search all .desktop files
|
||||
for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")):
|
||||
entry = parse_desktop_entry(file)
|
||||
if entry:
|
||||
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
||||
if exec_line:
|
||||
return exec_line
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_(".desktop file not found for game: {0}").format(game_name)
|
||||
)
|
||||
return None
|
||||
return exec_line
|
||||
|
||||
def _parse_exe_path(self, exec_line, game_name):
|
||||
"""Parse the executable path from exec_line."""
|
||||
try:
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Invalid executable command: {0}").format(exec_line)
|
||||
)
|
||||
return None
|
||||
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
||||
exe_path = entry_exec_split[2]
|
||||
elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) >= 4:
|
||||
exe_path = entry_exec_split[3]
|
||||
else:
|
||||
exe_path = entry_exec_split[-1]
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Executable file not found: {0}").format(exe_path or "None")
|
||||
)
|
||||
return None
|
||||
return exe_path
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Failed to parse executable command: {0}").format(e)
|
||||
)
|
||||
return None
|
||||
|
||||
def _remove_file(self, file_path, error_message, success_message, game_name):
|
||||
"""Remove a file and handle errors."""
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
|
||||
return True
|
||||
except OSError as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), error_message.format(e))
|
||||
return False
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
"""Delete the .desktop file and associated custom data for the game."""
|
||||
reply = QMessageBox.question(
|
||||
self.parent,
|
||||
_("Confirm Deletion"),
|
||||
_("Are you sure you want to delete '{0}'? This will remove the .desktop file and custom data.")
|
||||
.format(game_name),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not os.path.exists(desktop_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Get exec_line and parse exe_path
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
|
||||
|
||||
# Remove .desktop file
|
||||
if not self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to delete .desktop file: {0}"),
|
||||
_("Game '{0}' deleted successfully"),
|
||||
game_name
|
||||
):
|
||||
return
|
||||
|
||||
# Remove custom data if we got an exe_name
|
||||
if exe_name:
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
|
||||
if os.path.exists(custom_folder):
|
||||
try:
|
||||
shutil.rmtree(custom_folder)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Failed to delete custom data: {0}").format(e)
|
||||
)
|
||||
|
||||
# Refresh UI
|
||||
self.parent.games = self.load_games()
|
||||
self.update_game_grid()
|
||||
|
||||
def add_to_menu(self, game_name, exec_line):
|
||||
"""Copy the .desktop file to ~/.local/share/applications."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not os.path.exists(desktop_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Destination path
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
os.makedirs(applications_dir, exist_ok=True)
|
||||
dest_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
|
||||
# Copy .desktop file
|
||||
try:
|
||||
shutil.copyfile(desktop_path, dest_path)
|
||||
os.chmod(dest_path, 0o755) # Ensure executable permissions
|
||||
self.parent.statusBar().showMessage(_("Game '{0}' added to menu").format(game_name), 3000)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Failed to add game to menu: {0}").format(str(e))
|
||||
)
|
||||
|
||||
def remove_from_menu(self, game_name):
|
||||
"""Remove the .desktop file from ~/.local/share/applications."""
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
desktop_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to remove game from menu: {0}"),
|
||||
_("Game '{0}' removed from menu"),
|
||||
game_name
|
||||
)
|
||||
|
||||
def add_to_desktop(self, game_name, exec_line):
|
||||
"""Copy the .desktop file to Desktop folder."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not os.path.exists(desktop_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Destination path
|
||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
dest_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
||||
|
||||
# Copy .desktop file
|
||||
try:
|
||||
shutil.copyfile(desktop_path, dest_path)
|
||||
os.chmod(dest_path, 0o755) # Ensure executable permissions
|
||||
self.parent.statusBar().showMessage(_("Game '{0}' added to desktop").format(game_name), 3000)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
_("Failed to add game to desktop: {0}").format(str(e))
|
||||
)
|
||||
|
||||
def remove_from_desktop(self, game_name):
|
||||
"""Remove the .desktop file from Desktop folder."""
|
||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||
desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
||||
self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to remove game from Desktop: {0}"),
|
||||
_("Game '{0}' removed from Desktop"),
|
||||
game_name
|
||||
)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path):
|
||||
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
|
||||
from portprotonqt.dialogs import AddGameDialog # Local import to avoid circular dependency
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
# Open dialog in edit mode
|
||||
dialog = AddGameDialog(
|
||||
parent=self.parent,
|
||||
theme=self.theme,
|
||||
edit_mode=True,
|
||||
game_name=game_name,
|
||||
exe_path=exe_path,
|
||||
cover_path=cover_path
|
||||
)
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
new_exe_path = dialog.exeEdit.text().strip()
|
||||
new_cover_path = dialog.coverEdit.text().strip()
|
||||
|
||||
if not new_name or not new_exe_path:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Game name and executable path are required."))
|
||||
return
|
||||
|
||||
# Generate new .desktop file content
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to generate .desktop file data."))
|
||||
return
|
||||
|
||||
# If the name has changed, remove the old .desktop file
|
||||
old_desktop_path = self._get_desktop_path(game_name)
|
||||
if game_name != new_name and os.path.exists(old_desktop_path):
|
||||
self._remove_file(
|
||||
old_desktop_path,
|
||||
_("Failed to remove old .desktop file: {0}"),
|
||||
_("Old .desktop file removed for '{0}'"),
|
||||
game_name
|
||||
)
|
||||
|
||||
# Save the updated .desktop file
|
||||
try:
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to save .desktop file: {0}").format(e))
|
||||
return
|
||||
|
||||
# Update custom cover if provided
|
||||
if os.path.isfile(new_cover_path):
|
||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
ext = os.path.splitext(new_cover_path)[1].lower()
|
||||
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
||||
try:
|
||||
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
||||
except OSError as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to copy cover image: {0}").format(e))
|
||||
return
|
||||
|
||||
# Refresh the game list
|
||||
self.parent.games = self.load_games()
|
||||
self.update_game_grid()
|
||||
|
||||
def add_to_steam(self, game_name, exec_line, cover_path):
|
||||
"""Handle adding a non-Steam game to Steam via steam_api."""
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self.parent, _("Restart Steam"),
|
||||
_("The game was added successfully.\nPlease restart Steam for changes to take effect.")
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(self.parent, _("Error"), message)
|
||||
|
||||
def remove_from_steam(self, game_name, exec_line):
|
||||
"""Handle removing a non-Steam game from Steam via steam_api."""
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
success, message = remove_from_steam(game_name, exec_line)
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self.parent, _("Restart Steam"),
|
||||
_("The game was removed successfully.\nPlease restart Steam for changes to take effect.")
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(self.parent, _("Error"), message)
|
||||
|
||||
def open_game_folder(self, game_name, exec_line):
|
||||
"""Open the folder containing the game's executable."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
try:
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
self.parent.statusBar().showMessage(_("Opened folder for '{0}'").format(game_name), 3000)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e)))
|
0
portprotonqt/custom_data/.gitkeep
Normal file
393
portprotonqt/custom_widgets.py
Normal file
@ -0,0 +1,393 @@
|
||||
import numpy as np
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QStyleOption, QLayoutItem
|
||||
from PySide6.QtCore import Qt, Signal, QRect, QPoint, QSize
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
||||
|
||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
"""
|
||||
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
|
||||
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
|
||||
rect_width: доступная ширина контейнера.
|
||||
spacing: отступ между элементами.
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.2).
|
||||
|
||||
Возвращает:
|
||||
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
|
||||
total_height: итоговая высота всех рядов.
|
||||
"""
|
||||
N = nat_sizes.shape[0]
|
||||
result = np.zeros((N, 4), dtype=np.int32)
|
||||
y = 0
|
||||
i = 0
|
||||
while i < N:
|
||||
sum_width = 0
|
||||
row_max_height = 0
|
||||
count = 0
|
||||
j = i
|
||||
# Подбираем количество элементов для текущего ряда
|
||||
while j < N:
|
||||
w = nat_sizes[j, 0]
|
||||
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width:
|
||||
break
|
||||
sum_width += w
|
||||
count += 1
|
||||
h = nat_sizes[j, 1]
|
||||
if h > row_max_height:
|
||||
row_max_height = h
|
||||
j += 1
|
||||
# Доступная ширина ряда с учетом обязательных отступов между элементами
|
||||
available_width = rect_width - spacing * (count - 1)
|
||||
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
|
||||
# Разрешаем увеличение карточек, но не более max_scale
|
||||
scale = desired_scale if desired_scale < max_scale else max_scale
|
||||
# Выравниваем по левому краю (offset = 0)
|
||||
x = 0
|
||||
for k in range(i, j):
|
||||
new_w = int(nat_sizes[k, 0] * scale)
|
||||
new_h = int(nat_sizes[k, 1] * scale)
|
||||
result[k, 0] = x
|
||||
result[k, 1] = y
|
||||
result[k, 2] = new_w
|
||||
result[k, 3] = new_h
|
||||
x += new_w + spacing
|
||||
y += int(row_max_height * scale) + spacing
|
||||
i = j
|
||||
return result, y
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.itemList = []
|
||||
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self._spacing = 3 # отступ между карточками
|
||||
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
|
||||
|
||||
def addItem(self, item: QLayoutItem) -> None:
|
||||
self.itemList.append(item)
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem:
|
||||
if 0 <= index < len(self.itemList):
|
||||
return self.itemList.pop(index)
|
||||
raise IndexError("Index out of range")
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.itemList)
|
||||
|
||||
def itemAt(self, index: int) -> QLayoutItem | None:
|
||||
if 0 <= index < len(self.itemList):
|
||||
return self.itemList[index]
|
||||
return None
|
||||
|
||||
def expandingDirections(self):
|
||||
return Qt.Orientation(0)
|
||||
|
||||
def hasHeightForWidth(self):
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width):
|
||||
return self.doLayout(QRect(0, 0, width, 0), True)
|
||||
|
||||
def setGeometry(self, rect):
|
||||
super().setGeometry(rect)
|
||||
self.doLayout(rect, False)
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
size = QSize()
|
||||
for item in self.itemList:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(margins.left() + margins.right(),
|
||||
margins.top() + margins.bottom())
|
||||
return size
|
||||
|
||||
def doLayout(self, rect, testOnly):
|
||||
N = len(self.itemList)
|
||||
if N == 0:
|
||||
return 0
|
||||
|
||||
# Собираем натуральные размеры всех элементов в массив NumPy
|
||||
nat_sizes = np.empty((N, 2), dtype=np.int32)
|
||||
for i, item in enumerate(self.itemList):
|
||||
s = item.sizeHint()
|
||||
nat_sizes[i, 0] = s.width()
|
||||
nat_sizes[i, 1] = s.height()
|
||||
|
||||
# Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
|
||||
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
|
||||
|
||||
if not testOnly:
|
||||
for i, item in enumerate(self.itemList):
|
||||
x = geom_array[i, 0] + rect.x()
|
||||
y = geom_array[i, 1] + rect.y()
|
||||
w = geom_array[i, 2]
|
||||
h = geom_array[i, 3]
|
||||
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
|
||||
|
||||
return total_height
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, **kwargs):
|
||||
"""
|
||||
Поддерживаются вызовы:
|
||||
- ClickableLabel("текст", parent=...) – первый аргумент строка,
|
||||
- ClickableLabel(parent, text="...") – если первым аргументом передается родитель.
|
||||
|
||||
Аргументы:
|
||||
icon: QIcon или None – иконка, которая будет отрисована вместе с текстом.
|
||||
icon_size: int – размер иконки (ширина и высота).
|
||||
icon_space: int – отступ между иконкой и текстом.
|
||||
"""
|
||||
if args and isinstance(args[0], str):
|
||||
text = args[0]
|
||||
parent = kwargs.get("parent", None)
|
||||
super().__init__(text, parent)
|
||||
elif args and isinstance(args[0], QWidget):
|
||||
parent = args[0]
|
||||
text = kwargs.get("text", "")
|
||||
super().__init__(parent)
|
||||
self.setText(text)
|
||||
else:
|
||||
text = ""
|
||||
parent = kwargs.get("parent", None)
|
||||
super().__init__(text, parent)
|
||||
|
||||
self._icon = icon
|
||||
self._icon_size = icon_size
|
||||
self._icon_space = icon_space
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
def setIcon(self, icon):
|
||||
"""Устанавливает иконку и перерисовывает виджет."""
|
||||
self._icon = icon
|
||||
self.update()
|
||||
|
||||
def icon(self):
|
||||
"""Возвращает текущую иконку."""
|
||||
return self._icon
|
||||
|
||||
def paintEvent(self, event):
|
||||
"""Переопределяем отрисовку: рисуем иконку и текст в одном лейбле."""
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
rect = self.contentsRect()
|
||||
alignment = self.alignment()
|
||||
|
||||
icon_size = self._icon_size
|
||||
spacing = self._icon_space
|
||||
|
||||
icon_rect = QRect()
|
||||
text_rect = QRect()
|
||||
text = self.text()
|
||||
|
||||
if self._icon:
|
||||
# Получаем QPixmap нужного размера
|
||||
pixmap = self._icon.pixmap(icon_size, icon_size)
|
||||
icon_rect = QRect(0, 0, icon_size, icon_size)
|
||||
icon_rect.moveTop(rect.top() + (rect.height() - icon_size) // 2)
|
||||
else:
|
||||
pixmap = None
|
||||
|
||||
fm = QFontMetrics(self.font())
|
||||
text_width = fm.horizontalAdvance(text)
|
||||
text_height = fm.height()
|
||||
total_width = text_width + (icon_size + spacing if pixmap else 0)
|
||||
|
||||
if alignment & Qt.AlignmentFlag.AlignHCenter:
|
||||
x = rect.left() + (rect.width() - total_width) // 2
|
||||
elif alignment & Qt.AlignmentFlag.AlignRight:
|
||||
x = rect.right() - total_width
|
||||
else:
|
||||
x = rect.left()
|
||||
|
||||
y = rect.top() + (rect.height() - text_height) // 2
|
||||
|
||||
if pixmap:
|
||||
icon_rect.moveLeft(x)
|
||||
text_rect = QRect(x + icon_size + spacing, y, text_width, text_height)
|
||||
else:
|
||||
text_rect = QRect(x, y, text_width, text_height)
|
||||
|
||||
option = QStyleOption()
|
||||
option.initFrom(self)
|
||||
if pixmap:
|
||||
painter.drawPixmap(icon_rect, pixmap)
|
||||
self.style().drawItemText(
|
||||
painter,
|
||||
text_rect,
|
||||
alignment,
|
||||
self.palette(),
|
||||
self.isEnabled(),
|
||||
text,
|
||||
self.foregroundRole(),
|
||||
)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.clicked.emit()
|
||||
event.accept()
|
||||
else:
|
||||
super().mousePressEvent(event)
|
||||
|
||||
class AutoSizeButton(QPushButton):
|
||||
def __init__(self, *args, icon=None, icon_size=16,
|
||||
min_font_size=6, max_font_size=14, padding=20, update_size=True, **kwargs):
|
||||
if args and isinstance(args[0], str):
|
||||
text = args[0]
|
||||
parent = kwargs.get("parent", None)
|
||||
super().__init__(text, parent)
|
||||
elif args and isinstance(args[0], QWidget):
|
||||
parent = args[0]
|
||||
text = kwargs.get("text", "")
|
||||
super().__init__(text, parent)
|
||||
else:
|
||||
text = ""
|
||||
parent = kwargs.get("parent", None)
|
||||
super().__init__(text, parent)
|
||||
|
||||
self._icon = icon
|
||||
self._icon_size = icon_size
|
||||
self._alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
self._min_font_size = min_font_size
|
||||
self._max_font_size = max_font_size
|
||||
self._padding = padding
|
||||
self._update_size = update_size
|
||||
self._original_font = self.font()
|
||||
self._original_text = self.text()
|
||||
|
||||
if self._icon:
|
||||
self.setIcon(self._icon)
|
||||
self.setIconSize(QSize(self._icon_size, self._icon_size))
|
||||
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setFlat(True)
|
||||
|
||||
# Изначально выставляем минимальную ширину
|
||||
self.setMinimumWidth(50)
|
||||
self.adjustFontSize()
|
||||
|
||||
def setAlignment(self, alignment):
|
||||
self._alignment = alignment
|
||||
self.update()
|
||||
|
||||
def alignment(self):
|
||||
return self._alignment
|
||||
|
||||
def setText(self, text):
|
||||
self._original_text = text
|
||||
if not self._update_size:
|
||||
super().setText(text)
|
||||
else:
|
||||
super().setText(text)
|
||||
self.adjustFontSize()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if self._update_size:
|
||||
self.adjustFontSize()
|
||||
|
||||
def adjustFontSize(self):
|
||||
if not self._original_text:
|
||||
return
|
||||
|
||||
if not self._update_size:
|
||||
return
|
||||
|
||||
# Определяем доступную ширину внутри кнопки
|
||||
available_width = self.width()
|
||||
if self._icon:
|
||||
available_width -= self._icon_size
|
||||
|
||||
margins = self.contentsMargins()
|
||||
available_width -= (margins.left() + margins.right() + self._padding * 2)
|
||||
|
||||
font = QFont(self._original_font)
|
||||
text = self._original_text
|
||||
|
||||
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
|
||||
chosen_size = self._max_font_size
|
||||
for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
|
||||
font.setPointSize(font_size)
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(text)
|
||||
if text_width <= available_width:
|
||||
chosen_size = font_size
|
||||
break
|
||||
|
||||
font.setPointSize(chosen_size)
|
||||
self.setFont(font)
|
||||
|
||||
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(text)
|
||||
required_width = text_width + margins.left() + margins.right() + self._padding * 2
|
||||
if self._icon:
|
||||
required_width += self._icon_size
|
||||
|
||||
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
|
||||
if self.width() < required_width:
|
||||
self.setMinimumWidth(required_width)
|
||||
|
||||
super().setText(text)
|
||||
|
||||
def sizeHint(self):
|
||||
if not self._update_size:
|
||||
return super().sizeHint()
|
||||
else:
|
||||
# Вычисляем оптимальный размер кнопки на основе текста и отступов
|
||||
font = self.font()
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(self._original_text)
|
||||
margins = self.contentsMargins()
|
||||
width = text_width + margins.left() + margins.right() + self._padding * 2
|
||||
if self._icon:
|
||||
width += self._icon_size
|
||||
height = fm.height() + margins.top() + margins.bottom() + self._padding
|
||||
return QSize(width, height)
|
||||
|
||||
|
||||
class NavLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self, text="", parent=None):
|
||||
super().__init__(text, parent)
|
||||
self.setWordWrap(True)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
|
||||
self._checkable = False
|
||||
self._isChecked = False
|
||||
self.setProperty("checked", self._isChecked)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
# Explicitly enable focus
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
def setCheckable(self, checkable):
|
||||
self._checkable = checkable
|
||||
|
||||
def setChecked(self, checked):
|
||||
if self._checkable:
|
||||
self._isChecked = checked
|
||||
self.setProperty("checked", checked)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.update()
|
||||
|
||||
def isChecked(self):
|
||||
return self._isChecked
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# Ensure widget can take focus on click
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
if self._checkable:
|
||||
self.setChecked(not self._isChecked)
|
||||
self.clicked.emit()
|
||||
event.accept()
|
||||
else:
|
||||
super().mousePressEvent(event)
|
252
portprotonqt/dialogs.py
Normal file
@ -0,0 +1,252 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QLineEdit, QFormLayout, QPushButton,
|
||||
QHBoxLayout, QDialogButtonBox, QFileDialog, QLabel
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
from PIL import Image
|
||||
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
|
||||
"""
|
||||
Generates a thumbnail for an .exe file.
|
||||
|
||||
inputfile: the input file path (%i)
|
||||
outfile: output filename (%o)
|
||||
size: determines the thumbnail output size (%s)
|
||||
"""
|
||||
logger.debug(f"Начинаем генерацию миниатюры: {inputfile} → {outfile}, размер={size}, принудительно={force_resize}")
|
||||
|
||||
try:
|
||||
extractor = IconExtractor(inputfile)
|
||||
logger.debug("IconExtractor успешно создан.")
|
||||
except (RuntimeError, IconExtractorError) as e:
|
||||
logger.warning(f"Не удалось создать IconExtractor: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
data = extractor.get_icon()
|
||||
im = Image.open(data)
|
||||
logger.debug(f"Извлечена иконка размером {im.size}, форматы: {im.format}, кадры: {getattr(im, 'n_frames', 1)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при извлечении иконки: {e}")
|
||||
return False
|
||||
|
||||
if force_resize:
|
||||
logger.debug(f"Принудительное изменение размера иконки на {size}x{size}")
|
||||
im = im.resize((size, size))
|
||||
else:
|
||||
if size > 256:
|
||||
logger.warning('Запрошен размер больше 256, установлен 256')
|
||||
size = 256
|
||||
elif size not in (128, 256):
|
||||
logger.warning(f'Неподдерживаемый размер {size}, установлен 128')
|
||||
size = 128
|
||||
|
||||
if size == 256:
|
||||
logger.debug("Сохраняем иконку без изменения размера (256x256)")
|
||||
im.save(outfile, "PNG")
|
||||
logger.info(f"Иконка сохранена в {outfile}")
|
||||
return True
|
||||
|
||||
frames = getattr(im, 'n_frames', 1)
|
||||
try:
|
||||
for frame in range(frames):
|
||||
im.seek(frame)
|
||||
if im.size == (size, size):
|
||||
logger.debug(f"Найден кадр с размером {size}x{size}")
|
||||
break
|
||||
except EOFError:
|
||||
logger.debug("Кадры закончились до нахождения нужного размера.")
|
||||
|
||||
if im.size != (size, size):
|
||||
logger.debug(f"Изменение размера с {im.size} на {size}x{size}")
|
||||
im = im.resize((size, size))
|
||||
|
||||
try:
|
||||
im.save(outfile, "PNG")
|
||||
logger.info(f"Миниатюра успешно сохранена в {outfile}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сохранении миниатюры: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class AddGameDialog(QDialog):
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
|
||||
super().__init__(parent)
|
||||
self.theme = theme if theme else default_styles
|
||||
self.edit_mode = edit_mode
|
||||
self.original_name = game_name
|
||||
|
||||
self.setWindowTitle(_("Edit Game") if edit_mode else _("Add Game"))
|
||||
self.setModal(True)
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
|
||||
layout = QFormLayout(self)
|
||||
|
||||
# Game name
|
||||
self.nameEdit = QLineEdit(self)
|
||||
self.nameEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
|
||||
if game_name:
|
||||
self.nameEdit.setText(game_name)
|
||||
name_label = QLabel(_("Game Name:"))
|
||||
name_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
|
||||
layout.addRow(name_label, self.nameEdit)
|
||||
|
||||
# Exe path
|
||||
self.exeEdit = QLineEdit(self)
|
||||
self.exeEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
|
||||
if exe_path:
|
||||
self.exeEdit.setText(exe_path)
|
||||
exeBrowseButton = QPushButton(_("Browse..."), self)
|
||||
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
exeBrowseButton.clicked.connect(self.browseExe)
|
||||
|
||||
exeLayout = QHBoxLayout()
|
||||
exeLayout.addWidget(self.exeEdit)
|
||||
exeLayout.addWidget(exeBrowseButton)
|
||||
exe_label = QLabel(_("Path to Executable:"))
|
||||
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
|
||||
layout.addRow(exe_label, exeLayout)
|
||||
|
||||
# Cover path
|
||||
self.coverEdit = QLineEdit(self)
|
||||
self.coverEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
|
||||
if cover_path:
|
||||
self.coverEdit.setText(cover_path)
|
||||
coverBrowseButton = QPushButton(_("Browse..."), self)
|
||||
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
coverBrowseButton.clicked.connect(self.browseCover)
|
||||
|
||||
coverLayout = QHBoxLayout()
|
||||
coverLayout.addWidget(self.coverEdit)
|
||||
coverLayout.addWidget(coverBrowseButton)
|
||||
cover_label = QLabel(_("Custom Cover:"))
|
||||
cover_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
|
||||
layout.addRow(cover_label, coverLayout)
|
||||
|
||||
# Preview
|
||||
self.coverPreview = QLabel(self)
|
||||
self.coverPreview.setStyleSheet(self.theme.CONTENT_STYLE + " QLabel { color: #ffffff; }")
|
||||
preview_label = QLabel(_("Cover Preview:"))
|
||||
preview_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
|
||||
layout.addRow(preview_label, self.coverPreview)
|
||||
|
||||
# Dialog buttons
|
||||
buttonBox = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttonBox.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttonBox.accepted.connect(self.accept)
|
||||
buttonBox.rejected.connect(self.reject)
|
||||
layout.addRow(buttonBox)
|
||||
|
||||
self.coverEdit.textChanged.connect(self.updatePreview)
|
||||
self.exeEdit.textChanged.connect(self.updatePreview)
|
||||
|
||||
if edit_mode:
|
||||
self.updatePreview()
|
||||
|
||||
def browseExe(self):
|
||||
fileNameAndFilter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
_("Select Executable"),
|
||||
"",
|
||||
"Windows Executables (*.exe)"
|
||||
)
|
||||
fileName = fileNameAndFilter[0]
|
||||
if fileName:
|
||||
self.exeEdit.setText(fileName)
|
||||
if not self.edit_mode:
|
||||
self.nameEdit.setText(os.path.splitext(os.path.basename(fileName))[0])
|
||||
|
||||
def browseCover(self):
|
||||
fileNameAndFilter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
_("Select Cover Image"),
|
||||
"",
|
||||
"Images (*.png *.jpg *.jpeg *.bmp)"
|
||||
)
|
||||
fileName = fileNameAndFilter[0]
|
||||
if fileName:
|
||||
self.coverEdit.setText(fileName)
|
||||
|
||||
def updatePreview(self):
|
||||
"""Update the cover preview image."""
|
||||
cover_path = self.coverEdit.text().strip()
|
||||
exe_path = self.exeEdit.text().strip()
|
||||
if cover_path and os.path.isfile(cover_path):
|
||||
pixmap = QPixmap(cover_path)
|
||||
if not pixmap.isNull():
|
||||
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
else:
|
||||
self.coverPreview.setText(_("Invalid image"))
|
||||
elif os.path.isfile(exe_path):
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
|
||||
tmp.close()
|
||||
if generate_thumbnail(exe_path, tmp.name, size=128):
|
||||
pixmap = QPixmap(tmp.name)
|
||||
self.coverPreview.setPixmap(pixmap)
|
||||
os.unlink(tmp.name)
|
||||
else:
|
||||
self.coverPreview.setText(_("No cover selected"))
|
||||
|
||||
def getDesktopEntryData(self):
|
||||
"""Returns the .desktop content and save path"""
|
||||
exe_path = self.exeEdit.text().strip()
|
||||
name = self.nameEdit.text().strip()
|
||||
|
||||
if not exe_path or not name:
|
||||
return None, None
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
if portproton_path is None:
|
||||
return None, None
|
||||
|
||||
is_flatpak = ".var" in portproton_path
|
||||
base_path = os.path.join(portproton_path, "data")
|
||||
|
||||
if is_flatpak:
|
||||
exec_str = f'flatpak run ru.linux_gaming.PortProton "{exe_path}"'
|
||||
else:
|
||||
start_sh = os.path.join(base_path, "scripts", "start.sh")
|
||||
exec_str = f'env "{start_sh}" "{exe_path}"'
|
||||
|
||||
icon_path = os.path.join(base_path, "img", f"{name}.png")
|
||||
desktop_path = os.path.join(portproton_path, f"{name}.desktop")
|
||||
working_dir = os.path.join(base_path, "scripts")
|
||||
|
||||
user_cover_path = self.coverEdit.text().strip()
|
||||
if os.path.isfile(user_cover_path):
|
||||
shutil.copy(user_cover_path, icon_path)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
os.system(f'exe-thumbnailer "{exe_path}" "{icon_path}"')
|
||||
|
||||
comment = _('Launch game "{name}" with PortProton').format(name=name)
|
||||
|
||||
desktop_entry = f"""[Desktop Entry]
|
||||
Name={name}
|
||||
Comment={comment}
|
||||
Exec={exec_str}
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Game;
|
||||
StartupNotify=true
|
||||
Path={working_dir}
|
||||
Icon={icon_path}
|
||||
"""
|
||||
|
||||
return desktop_entry, desktop_path
|
310
portprotonqt/downloader.py
Normal file
@ -0,0 +1,310 @@
|
||||
from PySide6.QtCore import QObject, Signal, QThread
|
||||
import threading
|
||||
import os
|
||||
import requests
|
||||
import orjson
|
||||
import socket
|
||||
from pathlib import Path
|
||||
from tqdm import tqdm
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.config_utils import read_proxy_config
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def get_requests_session():
|
||||
session = requests.Session()
|
||||
proxy = read_proxy_config() or {}
|
||||
if proxy:
|
||||
session.proxies.update(proxy)
|
||||
session.verify = True
|
||||
return session
|
||||
|
||||
def download_with_cache(url, local_path, timeout=5, downloader_instance=None):
|
||||
if os.path.exists(local_path):
|
||||
return local_path
|
||||
session = get_requests_session()
|
||||
try:
|
||||
with session.get(url, stream=True, timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get('Content-Length', 0))
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
desc = Path(local_path).name
|
||||
with tqdm(total=total_size if total_size > 0 else None,
|
||||
unit='B', unit_scale=True, unit_divisor=1024,
|
||||
desc=f"Downloading {desc}", ascii=True) as pbar:
|
||||
with open(local_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
return local_path
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки {url}: {e}")
|
||||
if downloader_instance and hasattr(downloader_instance, '_last_error'):
|
||||
downloader_instance._last_error[url] = True
|
||||
if os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
return None
|
||||
|
||||
def download_with_parallel(urls, local_paths, max_workers=4, timeout=5, downloader_instance=None):
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
results = {}
|
||||
session = get_requests_session()
|
||||
|
||||
def _download_one(url, local_path):
|
||||
if os.path.exists(local_path):
|
||||
return local_path
|
||||
try:
|
||||
with session.get(url, stream=True, timeout=timeout) as response:
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get('Content-Length', 0))
|
||||
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
||||
desc = Path(local_path).name
|
||||
with tqdm(total=total_size if total_size > 0 else None,
|
||||
unit='B', unit_scale=True, unit_divisor=1024,
|
||||
desc=f"Downloading {desc}", ascii=True) as pbar:
|
||||
with open(local_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
return local_path
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки {url}: {e}")
|
||||
if downloader_instance and hasattr(downloader_instance, '_last_error'):
|
||||
downloader_instance._last_error[url] = True
|
||||
if os.path.exists(local_path):
|
||||
os.remove(local_path)
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_url = {executor.submit(_download_one, url, local_path): url for url, local_path in zip(urls, local_paths, strict=False)}
|
||||
for future in tqdm(as_completed(future_to_url), total=len(urls), desc="Downloading in parallel", ascii=True):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
res = future.result()
|
||||
results[url] = res
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при загрузке {url}: {e}")
|
||||
results[url] = None
|
||||
return results
|
||||
|
||||
class Downloader(QObject):
|
||||
download_completed = Signal(str, str, bool) # url, local_path, success
|
||||
|
||||
def __init__(self, max_workers=4):
|
||||
super().__init__()
|
||||
self.max_workers = max_workers
|
||||
self._cache = {}
|
||||
self._last_error = {}
|
||||
self._locks = {}
|
||||
self._active_threads: list[QThread] = []
|
||||
self._global_lock = threading.Lock()
|
||||
self._has_internet = None
|
||||
|
||||
def has_internet(self, timeout=3):
|
||||
if self._has_internet is None:
|
||||
errors = []
|
||||
try:
|
||||
socket.create_connection(("8.8.8.8", 53), timeout=timeout)
|
||||
except Exception as e:
|
||||
errors.append(f"8.8.8.8: {e}")
|
||||
try:
|
||||
socket.create_connection(("8.8.4.4", 53), timeout=timeout)
|
||||
except Exception as e:
|
||||
errors.append(f"8.8.4.4: {e}")
|
||||
try:
|
||||
requests.get("https://www.google.com", timeout=timeout)
|
||||
except Exception as e:
|
||||
errors.append(f"google.com: {e}")
|
||||
if errors:
|
||||
logger.warning("Интернет недоступен:\n" + "\n".join(errors))
|
||||
self._has_internet = False
|
||||
else:
|
||||
self._has_internet = True
|
||||
return self._has_internet
|
||||
|
||||
def reset_internet_check(self):
|
||||
self._has_internet = None
|
||||
|
||||
def _get_url_lock(self, url):
|
||||
with self._global_lock:
|
||||
if url not in self._locks:
|
||||
self._locks[url] = threading.Lock()
|
||||
return self._locks[url]
|
||||
|
||||
def download(self, url, local_path, timeout=5):
|
||||
if not self.has_internet():
|
||||
logger.warning(f"Нет интернета, пропускаем загрузку {url}")
|
||||
return None
|
||||
with self._global_lock:
|
||||
if url in self._last_error:
|
||||
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
||||
return None
|
||||
if url in self._cache:
|
||||
return self._cache[url]
|
||||
url_lock = self._get_url_lock(url)
|
||||
with url_lock:
|
||||
with self._global_lock:
|
||||
if url in self._last_error:
|
||||
return None
|
||||
if url in self._cache:
|
||||
return self._cache[url]
|
||||
result = download_with_cache(url, local_path, timeout, self)
|
||||
with self._global_lock:
|
||||
if result:
|
||||
self._cache[url] = result
|
||||
if url in self._locks:
|
||||
del self._locks[url]
|
||||
return result
|
||||
|
||||
def download_parallel(self, urls, local_paths, timeout=5):
|
||||
if not self.has_internet():
|
||||
logger.warning("Нет интернета, пропускаем параллельную загрузку")
|
||||
return dict.fromkeys(urls)
|
||||
|
||||
filtered_urls = []
|
||||
filtered_paths = []
|
||||
with self._global_lock:
|
||||
for url, path in zip(urls, local_paths, strict=False):
|
||||
if url in self._last_error:
|
||||
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
||||
continue
|
||||
if url in self._cache:
|
||||
continue
|
||||
filtered_urls.append(url)
|
||||
filtered_paths.append(path)
|
||||
|
||||
results = download_with_parallel(filtered_urls, filtered_paths, max_workers=self.max_workers, timeout=timeout, downloader_instance=self)
|
||||
|
||||
with self._global_lock:
|
||||
for url, path in results.items():
|
||||
if path:
|
||||
self._cache[url] = path
|
||||
# Для URL которые были пропущены, добавляем их из кэша или None
|
||||
final_results = {}
|
||||
with self._global_lock:
|
||||
for url in urls:
|
||||
if url in self._cache:
|
||||
final_results[url] = self._cache[url]
|
||||
else:
|
||||
final_results[url] = None
|
||||
return final_results
|
||||
|
||||
|
||||
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread:
|
||||
class DownloadThread(QThread):
|
||||
def __init__(self, downloader: 'Downloader', url: str, local_path: str, timeout: int, parallel: bool):
|
||||
super().__init__()
|
||||
self.downloader = downloader
|
||||
self.url = url
|
||||
self.local_path = local_path
|
||||
self.timeout = timeout
|
||||
self.parallel = parallel
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self.parallel:
|
||||
results = self.downloader.download_parallel([self.url], [self.local_path], timeout=self.timeout)
|
||||
result = results.get(self.url, None)
|
||||
else:
|
||||
result = self.downloader.download(self.url, self.local_path, self.timeout)
|
||||
success = result is not None
|
||||
logger.debug(f"Async download completed {self.url}: success={success}, path={result or ''}")
|
||||
self.downloader.download_completed.emit(self.url, result or "", success)
|
||||
if callback:
|
||||
callback(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при асинхронной загрузке {self.url}: {e}")
|
||||
self.downloader.download_completed.emit(self.url, "", False)
|
||||
if callback:
|
||||
callback(None)
|
||||
|
||||
thread = DownloadThread(self, url, local_path, timeout, parallel)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
# Удалить из списка после завершения
|
||||
def cleanup():
|
||||
self._active_threads.remove(thread)
|
||||
|
||||
thread.finished.connect(cleanup)
|
||||
|
||||
self._active_threads.append(thread) # Сохраняем поток, чтобы не уничтожился досрочно
|
||||
logger.debug(f"Запуск потока для асинхронной загрузки {url}")
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
def clear_cache(self):
|
||||
with self._global_lock:
|
||||
self._cache.clear()
|
||||
|
||||
def is_cached(self, url):
|
||||
with self._global_lock:
|
||||
return url in self._cache
|
||||
|
||||
def get_latest_legendary_release(self):
|
||||
"""Get the latest legendary release info from GitHub API."""
|
||||
try:
|
||||
api_url = "https://api.github.com/repos/derrod/legendary/releases/latest"
|
||||
response = requests.get(api_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
release_data = orjson.loads(response.content)
|
||||
|
||||
# Find the Linux binary asset
|
||||
for asset in release_data.get('assets', []):
|
||||
if asset['name'] == 'legendary' and 'linux' in asset.get('content_type', '').lower():
|
||||
return {
|
||||
'version': release_data['tag_name'],
|
||||
'download_url': asset['browser_download_url'],
|
||||
'size': asset['size']
|
||||
}
|
||||
|
||||
# Fallback: look for asset named just "legendary"
|
||||
for asset in release_data.get('assets', []):
|
||||
if asset['name'] == 'legendary':
|
||||
return {
|
||||
'version': release_data['tag_name'],
|
||||
'download_url': asset['browser_download_url'],
|
||||
'size': asset['size']
|
||||
}
|
||||
|
||||
logger.warning("Could not find legendary binary in latest release assets")
|
||||
return None
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch latest legendary release info: {e}")
|
||||
return None
|
||||
except (KeyError, orjson.JSONDecodeError) as e:
|
||||
logger.error(f"Failed to parse legendary release info: {e}")
|
||||
return None
|
||||
|
||||
def download_legendary_binary(self, callback: Callable[[str | None], None] | None = None):
|
||||
"""Download the latest legendary binary for Linux from GitHub releases."""
|
||||
if not self.has_internet():
|
||||
logger.warning("No internet connection, skipping legendary binary download")
|
||||
if callback:
|
||||
callback(None)
|
||||
return None
|
||||
|
||||
# Get latest release info
|
||||
latest_release = self.get_latest_legendary_release()
|
||||
if not latest_release:
|
||||
logger.error("Could not determine latest legendary version, falling back to hardcoded version")
|
||||
# Fallback to hardcoded version
|
||||
binary_url = "https://github.com/derrod/legendary/releases/download/0.20.34/legendary"
|
||||
version = "0.20.34"
|
||||
else:
|
||||
binary_url = latest_release['download_url']
|
||||
version = latest_release['version']
|
||||
logger.info(f"Found latest legendary version: {version}")
|
||||
|
||||
local_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQT", "legendary_cache", "legendary"
|
||||
)
|
||||
|
||||
logger.info(f"Downloading legendary binary version {version} from {binary_url} to {local_path}")
|
||||
return self.download_async(binary_url, local_path, timeout=5, callback=callback)
|
373
portprotonqt/egs_api.py
Normal file
@ -0,0 +1,373 @@
|
||||
import requests
|
||||
import threading
|
||||
import orjson
|
||||
from pathlib import Path
|
||||
import time
|
||||
import subprocess
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.localization import get_egs_language, _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def get_cache_dir() -> Path:
|
||||
"""Returns the path to the cache directory, creating it if necessary."""
|
||||
xdg_cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache")
|
||||
)
|
||||
cache_dir = Path(xdg_cache_home) / "PortProtonQT"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
def get_egs_game_description_async(
|
||||
app_name: str,
|
||||
callback: Callable[[str], None],
|
||||
cache_ttl: int = 3600
|
||||
) -> None:
|
||||
"""
|
||||
Asynchronously fetches the game description from the Epic Games Store API.
|
||||
Uses per-app cache files named egs_app_{app_name}.json in ~/.cache/PortProtonQT.
|
||||
Checks the cache first; if the description is cached and not expired, returns it.
|
||||
Prioritizes the page with type 'productHome' for the base game description.
|
||||
"""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json"
|
||||
|
||||
# Initialize content to avoid unbound variable
|
||||
content = b""
|
||||
# Load existing cache
|
||||
if cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, "rb") as f:
|
||||
content = f.read()
|
||||
cached_entry = orjson.loads(content)
|
||||
if not isinstance(cached_entry, dict):
|
||||
logger.warning(
|
||||
"Invalid cache format in %s: expected dict, got %s",
|
||||
cache_file,
|
||||
type(cached_entry)
|
||||
)
|
||||
cache_file.unlink(missing_ok=True)
|
||||
else:
|
||||
cached_time = cached_entry.get("timestamp", 0)
|
||||
if time.time() - cached_time < cache_ttl:
|
||||
description = cached_entry.get("description", "")
|
||||
logger.debug(
|
||||
"Using cached description for %s: %s",
|
||||
app_name,
|
||||
(description[:100] + "...") if len(description) > 100 else description
|
||||
)
|
||||
callback(description)
|
||||
return
|
||||
except orjson.JSONDecodeError as e:
|
||||
logger.warning(
|
||||
"Failed to parse description cache for %s: %s",
|
||||
app_name,
|
||||
str(e)
|
||||
)
|
||||
logger.debug(
|
||||
"Cache file content (first 100 chars): %s",
|
||||
content[:100].decode('utf-8', errors='replace')
|
||||
)
|
||||
cache_file.unlink(missing_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error reading description cache for %s: %s",
|
||||
app_name,
|
||||
str(e)
|
||||
)
|
||||
cache_file.unlink(missing_ok=True)
|
||||
|
||||
lang = get_egs_language()
|
||||
slug = app_name.lower().replace(":", "").replace(" ", "-")
|
||||
url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{slug}"
|
||||
|
||||
def fetch_description():
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Invalid JSON structure for %s: %s", app_name, type(data))
|
||||
callback("")
|
||||
return
|
||||
|
||||
description = ""
|
||||
pages = data.get("pages", [])
|
||||
if pages:
|
||||
# Look for the page with type "productHome" for the base game
|
||||
for page in pages:
|
||||
if page.get("type") == "productHome":
|
||||
about_data = page.get("data", {}).get("about", {})
|
||||
description = about_data.get("shortDescription", "")
|
||||
break
|
||||
else:
|
||||
# Fallback to first page's description if no productHome is found
|
||||
description = (
|
||||
pages[0].get("data", {})
|
||||
.get("about", {})
|
||||
.get("shortDescription", "")
|
||||
)
|
||||
|
||||
if not description:
|
||||
logger.warning("No valid description found for %s", app_name)
|
||||
|
||||
logger.debug(
|
||||
"Fetched EGS description for %s: %s",
|
||||
app_name,
|
||||
(description[:100] + "...") if len(description) > 100 else description
|
||||
)
|
||||
|
||||
cache_entry = {"description": description, "timestamp": time.time()}
|
||||
try:
|
||||
temp_file = cache_file.with_suffix('.tmp')
|
||||
with open(temp_file, "wb") as f:
|
||||
f.write(orjson.dumps(cache_entry))
|
||||
temp_file.replace(cache_file)
|
||||
logger.debug(
|
||||
"Saved description to cache for %s", app_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to save description cache for %s: %s",
|
||||
app_name,
|
||||
str(e)
|
||||
)
|
||||
callback(description)
|
||||
except requests.RequestException as e:
|
||||
logger.warning(
|
||||
"Failed to fetch EGS description for %s: %s",
|
||||
app_name,
|
||||
str(e)
|
||||
)
|
||||
callback("")
|
||||
except orjson.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Invalid JSON response for %s", app_name
|
||||
)
|
||||
callback("")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Unexpected error fetching EGS description for %s: %s",
|
||||
app_name,
|
||||
str(e)
|
||||
)
|
||||
callback("")
|
||||
|
||||
thread = threading.Thread(
|
||||
target=fetch_description,
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
||||
"""
|
||||
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
||||
"""
|
||||
def execute_command():
|
||||
process = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[legendary_path, "list", "--json"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False
|
||||
)
|
||||
stdout, stderr = process.communicate(timeout=30)
|
||||
if process.returncode != 0:
|
||||
logger.error("Legendary list command failed: %s", stderr.decode('utf-8', errors='replace'))
|
||||
callback(None)
|
||||
return
|
||||
try:
|
||||
result = orjson.loads(stdout)
|
||||
if not isinstance(result, list):
|
||||
logger.error("Invalid legendary output format: expected list, got %s", type(result))
|
||||
callback(None)
|
||||
return
|
||||
callback(result)
|
||||
except orjson.JSONDecodeError as e:
|
||||
logger.error("Failed to parse JSON output from legendary list: %s", str(e))
|
||||
callback(None)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Legendary list command timed out")
|
||||
if process:
|
||||
process.kill()
|
||||
callback(None)
|
||||
except FileNotFoundError:
|
||||
logger.error("Legendary executable not found at %s", legendary_path)
|
||||
callback(None)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error executing legendary list: %s", str(e))
|
||||
callback(None)
|
||||
|
||||
threading.Thread(target=execute_command, daemon=True).start()
|
||||
|
||||
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
||||
"""
|
||||
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
|
||||
"""
|
||||
logger.debug("Starting to load Epic Games Store games")
|
||||
games: list[tuple] = []
|
||||
cache_dir = Path(os.path.dirname(legendary_path))
|
||||
metadata_dir = cache_dir / "metadata"
|
||||
cache_file = cache_dir / "legendary_games.json"
|
||||
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
|
||||
|
||||
if not os.path.exists(legendary_path):
|
||||
logger.info("Legendary binary not found, downloading...")
|
||||
def on_legendary_downloaded(result):
|
||||
if result:
|
||||
logger.info("Legendary binary downloaded successfully")
|
||||
try:
|
||||
os.chmod(legendary_path, 0o755)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to make legendary binary executable: {e}")
|
||||
callback(games) # Return empty games list on failure
|
||||
return
|
||||
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
|
||||
else:
|
||||
logger.error("Failed to download legendary binary")
|
||||
callback(games) # Return empty games list on failure
|
||||
try:
|
||||
downloader.download_legendary_binary(on_legendary_downloaded)
|
||||
except Exception as e:
|
||||
logger.error(f"Error initiating legendary binary download: {e}")
|
||||
callback(games)
|
||||
return
|
||||
else:
|
||||
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
|
||||
|
||||
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
||||
"""
|
||||
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
|
||||
"""
|
||||
games: list[tuple] = []
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def process_games(installed_games: list | None):
|
||||
if installed_games is None:
|
||||
logger.info("No installed Epic Games Store games found")
|
||||
callback(games)
|
||||
return
|
||||
|
||||
# Сохраняем в кэш
|
||||
try:
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(installed_games))
|
||||
logger.debug("Saved Epic Games Store games to cache: %s", cache_file)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save cache: %s", str(e))
|
||||
|
||||
# Фильтруем игры
|
||||
valid_games = [game for game in installed_games if isinstance(game, dict) and game.get("app_name") and not game.get("is_dlc", False)]
|
||||
if len(valid_games) != len(installed_games):
|
||||
logger.warning("Filtered out %d invalid game records", len(installed_games) - len(valid_games))
|
||||
|
||||
if not valid_games:
|
||||
logger.info("No valid Epic Games Store games found after filtering")
|
||||
callback(games)
|
||||
return
|
||||
|
||||
pending_images = len(valid_games)
|
||||
total_games = len(valid_games)
|
||||
update_progress(0)
|
||||
update_status_message(_("Loading Epic Games Store games..."), 3000)
|
||||
|
||||
game_results: dict[int, tuple] = {}
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def process_game_metadata(game, index):
|
||||
nonlocal pending_images
|
||||
app_name = game.get("app_name", "")
|
||||
title = game.get("app_title", app_name)
|
||||
if not app_name:
|
||||
with results_lock:
|
||||
pending_images -= 1
|
||||
update_progress(total_games - pending_images)
|
||||
if pending_images == 0:
|
||||
final_games = [game_results[i] for i in sorted(game_results.keys())]
|
||||
callback(final_games)
|
||||
return
|
||||
|
||||
metadata_file = metadata_dir / f"{app_name}.json"
|
||||
cover_url = ""
|
||||
try:
|
||||
with open(metadata_file, "rb") as f:
|
||||
metadata = orjson.loads(f.read())
|
||||
key_images = metadata.get("metadata", {}).get("keyImages", [])
|
||||
for img in key_images:
|
||||
if isinstance(img, dict) and img.get("type") in ["DieselGameBoxTall", "Thumbnail"]:
|
||||
cover_url = img.get("url", "")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("Error processing metadata for %s: %s", app_name, str(e))
|
||||
|
||||
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQT", "images")
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
||||
|
||||
def on_description_fetched(api_description: str):
|
||||
final_description = api_description or _("No description available")
|
||||
|
||||
def on_cover_loaded(pixmap: QPixmap):
|
||||
from portprotonqt.steam_api import get_weanticheatyet_status_async
|
||||
def on_anticheat_status(status: str):
|
||||
nonlocal pending_images
|
||||
with results_lock:
|
||||
game_results[index] = (
|
||||
title,
|
||||
final_description,
|
||||
local_path if os.path.exists(local_path) else "",
|
||||
app_name,
|
||||
f"legendary:launch:{app_name}",
|
||||
"",
|
||||
_("Never"),
|
||||
"",
|
||||
"",
|
||||
status or "",
|
||||
0,
|
||||
0,
|
||||
"epic"
|
||||
)
|
||||
pending_images -= 1
|
||||
update_progress(total_games - pending_images)
|
||||
if pending_images == 0:
|
||||
final_games = [game_results[i] for i in sorted(game_results.keys())]
|
||||
callback(final_games)
|
||||
|
||||
get_weanticheatyet_status_async(title, on_anticheat_status)
|
||||
|
||||
load_pixmap_async(cover_url, 600, 900, on_cover_loaded, app_name=app_name)
|
||||
|
||||
get_egs_game_description_async(title, on_description_fetched)
|
||||
|
||||
max_workers = min(4, len(valid_games))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for i, game in enumerate(valid_games):
|
||||
executor.submit(process_game_metadata, game, i)
|
||||
|
||||
# Проверяем кэш
|
||||
use_cache = False
|
||||
if cache_file.exists():
|
||||
try:
|
||||
cache_mtime = cache_file.stat().st_mtime
|
||||
if time.time() - cache_mtime < cache_ttl and metadata_dir.exists() and any(metadata_dir.iterdir()):
|
||||
logger.debug("Loading Epic Games Store games from cache: %s", cache_file)
|
||||
with open(cache_file, "rb") as f:
|
||||
installed_games = orjson.loads(f.read())
|
||||
if not isinstance(installed_games, list):
|
||||
logger.warning("Invalid cache format: expected list, got %s", type(installed_games))
|
||||
else:
|
||||
use_cache = True
|
||||
process_games(installed_games)
|
||||
except Exception as e:
|
||||
logger.error("Error reading cache: %s", str(e))
|
||||
|
||||
if not use_cache:
|
||||
logger.info("Fetching Epic Games Store games using legendary list")
|
||||
run_legendary_list_async(legendary_path, process_games)
|
473
portprotonqt/game_card.py
Normal file
@ -0,0 +1,473 @@
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices
|
||||
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
|
||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||
from collections.abc import Callable
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import read_favorites, save_favorites
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.custom_widgets import ClickableLabel
|
||||
import weakref
|
||||
from typing import cast
|
||||
|
||||
class GameCard(QFrame):
|
||||
borderWidthChanged = Signal()
|
||||
gradientAngleChanged = Signal()
|
||||
# Signals for context menu actions
|
||||
editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path
|
||||
deleteGameRequested = Signal(str, str) # name, exec_line
|
||||
addToMenuRequested = Signal(str, str) # name, exec_line
|
||||
removeFromMenuRequested = Signal(str) # name
|
||||
addToDesktopRequested = Signal(str, str) # name, exec_line
|
||||
removeFromDesktopRequested = Signal(str) # name
|
||||
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
|
||||
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
||||
openGameFolderRequested = Signal(str, str) # name, exec_line
|
||||
|
||||
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
|
||||
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, steam_game,
|
||||
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
|
||||
super().__init__(parent)
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.cover_path = cover_path
|
||||
self.appid = appid
|
||||
self.controller_support = controller_support
|
||||
self.exec_line = exec_line
|
||||
self.last_launch = last_launch
|
||||
self.formatted_playtime = formatted_playtime
|
||||
self.protondb_tier = protondb_tier
|
||||
self.anticheat_status = anticheat_status
|
||||
self.steam_game = steam_game
|
||||
self.last_launch_ts = last_launch_ts
|
||||
self.playtime_seconds = playtime_seconds
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else default_styles
|
||||
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
|
||||
# Дополнительное пространство для анимации
|
||||
extra_margin = 20
|
||||
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
# Параметры анимации обводки
|
||||
self._borderWidth = 2
|
||||
self._gradientAngle = 0.0
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
|
||||
# Анимации
|
||||
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(300)
|
||||
self.gradient_anim = None
|
||||
self.pulse_anim = None
|
||||
|
||||
# Флаг для отслеживания подключения слота startPulseAnimation
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
# Тень
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(20)
|
||||
shadow.setColor(QColor(0, 0, 0, 150))
|
||||
shadow.setOffset(0, 0)
|
||||
self.setGraphicsEffect(shadow)
|
||||
|
||||
# Отступы
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Контейнер обложки
|
||||
coverWidget = QWidget()
|
||||
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
|
||||
coverLayout = QStackedLayout(coverWidget)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
|
||||
# Обложка
|
||||
self.coverLabel = QLabel()
|
||||
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
|
||||
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
||||
coverLayout.addWidget(self.coverLabel)
|
||||
|
||||
# создаём слабую ссылку на label
|
||||
label_ref = weakref.ref(self.coverLabel)
|
||||
|
||||
def on_cover_loaded(pixmap):
|
||||
label = label_ref()
|
||||
if label is None:
|
||||
# QLabel уже удалён — ничего не делаем
|
||||
return
|
||||
label.setPixmap(round_corners(pixmap, 15))
|
||||
|
||||
# асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
|
||||
load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
|
||||
|
||||
# Значок избранного (звёздочка) в левом верхнем углу обложки
|
||||
self.favoriteLabel = ClickableLabel(coverWidget)
|
||||
self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
|
||||
self.favoriteLabel.move(8, 8)
|
||||
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
||||
self.is_favorite = self.name in read_favorites()
|
||||
self.update_favorite_icon()
|
||||
self.favoriteLabel.raise_()
|
||||
|
||||
# ProtonDB бейдж
|
||||
tier_text = self.getProtonDBText(protondb_tier)
|
||||
if tier_text:
|
||||
icon_filename = self.getProtonDBIconFilename(protondb_tier)
|
||||
icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name)
|
||||
self.protondbLabel = ClickableLabel(
|
||||
tier_text,
|
||||
icon=icon,
|
||||
parent=coverWidget,
|
||||
icon_size=16,
|
||||
icon_space=3,
|
||||
)
|
||||
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
||||
self.protondbLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
|
||||
protondb_visible = True
|
||||
else:
|
||||
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
||||
self.protondbLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину даже для невидимого бейджа
|
||||
self.protondbLabel.setVisible(False)
|
||||
protondb_visible = False
|
||||
|
||||
# Steam бейдж
|
||||
steam_icon = self.theme_manager.get_icon("steam")
|
||||
self.steamLabel = ClickableLabel(
|
||||
"Steam",
|
||||
icon=steam_icon,
|
||||
parent=coverWidget,
|
||||
icon_size=16,
|
||||
icon_space=5,
|
||||
)
|
||||
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
self.steamLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
|
||||
steam_visible = (str(steam_game).lower() == "true")
|
||||
self.steamLabel.setVisible(steam_visible)
|
||||
|
||||
# WeAntiCheatYet бейдж
|
||||
anticheat_text = self.getAntiCheatText(anticheat_status)
|
||||
if anticheat_text:
|
||||
icon_filename = self.getAntiCheatIconFilename(anticheat_status)
|
||||
icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name)
|
||||
self.anticheatLabel = ClickableLabel(
|
||||
anticheat_text,
|
||||
icon=icon,
|
||||
parent=coverWidget,
|
||||
icon_size=16,
|
||||
icon_space=3,
|
||||
)
|
||||
self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
|
||||
anticheat_visible = True
|
||||
else:
|
||||
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
||||
self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину даже для невидимого бейджа
|
||||
self.anticheatLabel.setVisible(False)
|
||||
anticheat_visible = False
|
||||
|
||||
# Расположение бейджей
|
||||
right_margin = 8
|
||||
badge_spacing = 5
|
||||
top_y = 10
|
||||
badge_y_positions = []
|
||||
badge_width = int(card_width * 2/3) # Фиксированная ширина бейджей
|
||||
if steam_visible:
|
||||
steam_x = card_width - badge_width - right_margin
|
||||
self.steamLabel.move(steam_x, top_y)
|
||||
badge_y_positions.append(top_y + self.steamLabel.height())
|
||||
if protondb_visible:
|
||||
protondb_x = card_width - badge_width - right_margin
|
||||
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
self.protondbLabel.move(protondb_x, protondb_y)
|
||||
badge_y_positions.append(protondb_y + self.protondbLabel.height())
|
||||
if anticheat_visible:
|
||||
anticheat_x = card_width - badge_width - right_margin
|
||||
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
self.anticheatLabel.move(anticheat_x, anticheat_y)
|
||||
|
||||
self.anticheatLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
||||
self.steamLabel.clicked.connect(self.open_steam_page)
|
||||
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
|
||||
|
||||
layout.addWidget(coverWidget)
|
||||
|
||||
# Название игры
|
||||
nameLabel = QLabel(name)
|
||||
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
layout.addWidget(nameLabel)
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
"""Delegate context menu display to ContextMenuManager."""
|
||||
if self.context_menu_manager:
|
||||
self.context_menu_manager.show_context_menu(self, pos)
|
||||
|
||||
def getAntiCheatText(self, status):
|
||||
if not status:
|
||||
return ""
|
||||
translations = {
|
||||
"supported": _("Supported"),
|
||||
"running": _("Running"),
|
||||
"planned": _("Planned"),
|
||||
"broken": _("Broken"),
|
||||
"denied": _("Denied")
|
||||
}
|
||||
return translations.get(status.lower(), "")
|
||||
|
||||
def getAntiCheatIconFilename(self, status):
|
||||
status = status.lower()
|
||||
if status in ("supported", "running"):
|
||||
return "platinum-gold"
|
||||
elif status in ("denied", "planned", "broken"):
|
||||
return "broken"
|
||||
return ""
|
||||
|
||||
def getProtonDBText(self, tier):
|
||||
if not tier:
|
||||
return ""
|
||||
translations = {
|
||||
"platinum": _("Platinum"),
|
||||
"gold": _("Gold"),
|
||||
"silver": _("Silver"),
|
||||
"bronze": _("Bronze"),
|
||||
"borked": _("Broken"),
|
||||
"pending": _("Pending")
|
||||
}
|
||||
return translations.get(tier.lower(), "")
|
||||
|
||||
def getProtonDBIconFilename(self, tier):
|
||||
tier = tier.lower()
|
||||
if tier in ("platinum", "gold"):
|
||||
return "platinum-gold"
|
||||
elif tier in ("silver", "bronze"):
|
||||
return "silver-bronze"
|
||||
elif tier in ("borked", "pending"):
|
||||
return "broken"
|
||||
return ""
|
||||
|
||||
def open_protondb_report(self):
|
||||
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def open_steam_page(self):
|
||||
url = QUrl(f"https://steamcommunity.com/app/{self.appid}")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def open_weanticheatyet_page(self):
|
||||
formatted_name = self.name.lower().replace(" ", "-")
|
||||
url = QUrl(f"https://areweanticheatyet.com/game/{formatted_name}")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def update_favorite_icon(self):
|
||||
if self.is_favorite:
|
||||
self.favoriteLabel.setText("★")
|
||||
else:
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
|
||||
def toggle_favorite(self):
|
||||
favorites = read_favorites()
|
||||
if self.is_favorite:
|
||||
if self.name in favorites:
|
||||
favorites.remove(self.name)
|
||||
self.is_favorite = False
|
||||
else:
|
||||
if self.name not in favorites:
|
||||
favorites.append(self.name)
|
||||
self.is_favorite = True
|
||||
save_favorites(favorites)
|
||||
self.update_favorite_icon()
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int):
|
||||
if self._borderWidth != value:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float):
|
||||
if self._gradientAngle != value:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
pen = QPen()
|
||||
pen.setWidth(self._borderWidth)
|
||||
if self._hovered or self._focused:
|
||||
center = self.rect().center()
|
||||
gradient = QConicalGradient(center, self._gradientAngle)
|
||||
gradient.setColorAt(0, QColor("#00fff5"))
|
||||
gradient.setColorAt(0.33, QColor("#FF5733"))
|
||||
gradient.setColorAt(0.66, QColor("#9B59B6"))
|
||||
gradient.setColorAt(1, QColor("#00fff5"))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
|
||||
painter.setPen(pen)
|
||||
radius = 18
|
||||
bw = round(self._borderWidth / 2)
|
||||
rect = self.rect().adjusted(bw, bw, -bw, -bw)
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
def startPulseAnimation(self):
|
||||
if not (self._hovered or self._focused):
|
||||
return
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(800)
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, 8)
|
||||
self.pulse_anim.setKeyValueAt(0.5, 10)
|
||||
self.pulse_anim.setKeyValueAt(1, 8)
|
||||
self.pulse_anim.start()
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(8)
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(3000)
|
||||
self.gradient_anim.setStartValue(360)
|
||||
self.gradient_anim.setEndValue(0)
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hovered = False
|
||||
if not self._focused: # Сохраняем анимацию, если есть фокус
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(2)
|
||||
self.thickness_anim.start()
|
||||
|
||||
super().leaveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
self._focused = True
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(12)
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(3000)
|
||||
self.gradient_anim.setStartValue(360)
|
||||
self.gradient_anim.setEndValue(0)
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self._focused = False
|
||||
if not self._hovered: # Сохраняем анимацию, если есть наведение
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(2)
|
||||
self.thickness_anim.start()
|
||||
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.select_callback(
|
||||
self.name,
|
||||
self.description,
|
||||
self.cover_path,
|
||||
self.appid,
|
||||
self.controller_support,
|
||||
self.exec_line,
|
||||
self.last_launch,
|
||||
self.formatted_playtime,
|
||||
self.protondb_tier,
|
||||
self.steam_game
|
||||
)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self.select_callback(
|
||||
self.name,
|
||||
self.description,
|
||||
self.cover_path,
|
||||
self.appid,
|
||||
self.controller_support,
|
||||
self.exec_line,
|
||||
self.last_launch,
|
||||
self.formatted_playtime,
|
||||
self.protondb_tier,
|
||||
self.steam_game
|
||||
)
|
||||
else:
|
||||
super().keyPressEvent(event)
|
503
portprotonqt/image_utils.py
Normal file
@ -0,0 +1,503 @@
|
||||
import os
|
||||
from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
|
||||
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
|
||||
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
|
||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.logger import get_logger
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from queue import Queue
|
||||
import threading
|
||||
|
||||
downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Глобальная очередь и пул потоков для загрузки изображений
|
||||
image_load_queue = Queue()
|
||||
image_executor = ThreadPoolExecutor(max_workers=4)
|
||||
queue_lock = threading.Lock()
|
||||
|
||||
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
|
||||
"""
|
||||
Асинхронно загружает обложку через очередь задач.
|
||||
"""
|
||||
def process_image():
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
def finish_with(pixmap: QPixmap):
|
||||
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||
x = (scaled.width() - width) // 2
|
||||
y = (scaled.height() - height) // 2
|
||||
cropped = scaled.copy(x, y, width, height)
|
||||
callback(cropped)
|
||||
# Removed: pixmap = None (unnecessary, causes type error)
|
||||
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQT", "images")
|
||||
os.makedirs(image_folder, exist_ok=True)
|
||||
|
||||
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
appid = None
|
||||
if "apps" in parts:
|
||||
idx = parts.index("apps")
|
||||
if idx + 1 < len(parts):
|
||||
appid = parts[idx + 1]
|
||||
if appid:
|
||||
local_path = os.path.join(image_folder, f"{appid}.jpg")
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
def on_downloaded(result: str | None):
|
||||
pixmap = QPixmap()
|
||||
if result and os.path.exists(result):
|
||||
pixmap.load(result)
|
||||
if pixmap.isNull():
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки URL {cover}: {e}")
|
||||
|
||||
if cover and cover.startswith(("http://", "https://")):
|
||||
try:
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg")
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
def on_downloaded(result: str | None):
|
||||
pixmap = QPixmap()
|
||||
if result and os.path.exists(result):
|
||||
pixmap.load(result)
|
||||
if pixmap.isNull():
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Error processing EGS URL %s: %s", cover, str(e))
|
||||
|
||||
if cover and QFile.exists(cover):
|
||||
pixmap = QPixmap(cover)
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
pixmap = QPixmap()
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
with queue_lock:
|
||||
image_load_queue.put(process_image)
|
||||
image_executor.submit(lambda: image_load_queue.get()())
|
||||
|
||||
def round_corners(pixmap, radius):
|
||||
"""
|
||||
Возвращает QPixmap с закруглёнными углами.
|
||||
"""
|
||||
if pixmap.isNull():
|
||||
return pixmap
|
||||
size = pixmap.size()
|
||||
rounded = QPixmap(size)
|
||||
rounded.fill(QColor(0, 0, 0, 0))
|
||||
painter = QPainter(rounded)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(0, 0, size.width(), size.height(), radius, radius)
|
||||
painter.setClipPath(path)
|
||||
painter.drawPixmap(0, 0, pixmap)
|
||||
painter.end()
|
||||
return rounded
|
||||
|
||||
class FullscreenDialog(QDialog):
|
||||
"""
|
||||
Диалог для просмотра изображений без стандартных элементов управления.
|
||||
Изображение отображается в области фиксированного размера, а подпись располагается чуть выше нижней границы.
|
||||
В окне есть кнопки-стрелки для перелистывания изображений.
|
||||
Диалог закрывается при клике по изображению или подписи.
|
||||
"""
|
||||
FIXED_WIDTH = 800
|
||||
FIXED_HEIGHT = 400
|
||||
|
||||
def __init__(self, images, current_index=0, parent=None, theme=None):
|
||||
"""
|
||||
:param images: Список кортежей (QPixmap, caption)
|
||||
:param current_index: Индекс текущего изображения
|
||||
:param theme: Объект темы для стилизации (если None, используется default_styles)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
# Удаление диалога после закрытия
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFocus()
|
||||
|
||||
self.images = images
|
||||
self.current_index = current_index
|
||||
self.theme = theme if theme else default_styles
|
||||
|
||||
# Убираем стандартные элементы управления окна
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
self.init_ui()
|
||||
self.update_display()
|
||||
|
||||
# Фильтруем события для закрытия диалога по клику
|
||||
self.imageLabel.installEventFilter(self)
|
||||
self.captionLabel.installEventFilter(self)
|
||||
|
||||
def init_ui(self):
|
||||
self.mainLayout = QVBoxLayout(self)
|
||||
self.setLayout(self.mainLayout)
|
||||
self.mainLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.mainLayout.setSpacing(0)
|
||||
|
||||
# Контейнер для изображения и стрелок
|
||||
self.imageContainer = QWidget()
|
||||
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
|
||||
self.imageContainerLayout = QHBoxLayout(self.imageContainer)
|
||||
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.imageContainerLayout.setSpacing(0)
|
||||
|
||||
# Левая стрелка
|
||||
self.prevButton = QToolButton()
|
||||
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
|
||||
self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE)
|
||||
self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.prevButton.setFixedSize(40, 40)
|
||||
self.prevButton.clicked.connect(self.show_prev)
|
||||
self.imageContainerLayout.addWidget(self.prevButton)
|
||||
|
||||
# Метка для изображения
|
||||
self.imageLabel = QLabel()
|
||||
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
|
||||
|
||||
# Правая стрелка
|
||||
self.nextButton = QToolButton()
|
||||
self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
|
||||
self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE)
|
||||
self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.nextButton.setFixedSize(40, 40)
|
||||
self.nextButton.clicked.connect(self.show_next)
|
||||
self.imageContainerLayout.addWidget(self.nextButton)
|
||||
|
||||
self.mainLayout.addWidget(self.imageContainer)
|
||||
|
||||
# Небольшой отступ между изображением и подписью
|
||||
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.mainLayout.addItem(spacer)
|
||||
|
||||
# Подпись
|
||||
self.captionLabel = QLabel()
|
||||
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.captionLabel.setFixedHeight(40)
|
||||
self.captionLabel.setWordWrap(True)
|
||||
self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE)
|
||||
self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.mainLayout.addWidget(self.captionLabel)
|
||||
|
||||
def update_display(self):
|
||||
"""Обновляет изображение и подпись согласно текущему индексу."""
|
||||
if not self.images:
|
||||
return
|
||||
|
||||
# Очищаем старое содержимое
|
||||
self.imageLabel.clear()
|
||||
self.captionLabel.clear()
|
||||
QApplication.processEvents()
|
||||
|
||||
pixmap, caption = self.images[self.current_index]
|
||||
# Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера
|
||||
scaled_pixmap = pixmap.scaled(
|
||||
self.FIXED_WIDTH - 80, # учитываем ширину стрелок
|
||||
self.FIXED_HEIGHT,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.imageLabel.setPixmap(scaled_pixmap)
|
||||
self.captionLabel.setText(caption)
|
||||
self.setWindowTitle(caption)
|
||||
|
||||
# Принудительная перерисовка виджетов
|
||||
self.imageLabel.repaint()
|
||||
self.captionLabel.repaint()
|
||||
self.repaint()
|
||||
|
||||
def show_prev(self):
|
||||
"""Показывает предыдущее изображение."""
|
||||
if self.images:
|
||||
self.current_index = (self.current_index - 1) % len(self.images)
|
||||
self.update_display()
|
||||
|
||||
def show_next(self):
|
||||
"""Показывает следующее изображение."""
|
||||
if self.images:
|
||||
self.current_index = (self.current_index + 1) % len(self.images)
|
||||
self.update_display()
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
"""Закрывает диалог при клике по изображению или подписи."""
|
||||
if event.type() == QEvent.Type.MouseButtonPress and obj in [self.imageLabel, self.captionLabel]:
|
||||
self.close()
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def changeEvent(self, event):
|
||||
"""Закрывает диалог при потере фокуса."""
|
||||
if event.type() == QEvent.Type.ActivationChange:
|
||||
if not self.isActiveWindow():
|
||||
self.close()
|
||||
super().changeEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
"""Закрывает диалог при клике на пустую область."""
|
||||
pos = event.pos()
|
||||
# Проверяем, находится ли клик вне imageContainer и captionLabel
|
||||
if not (self.imageContainer.geometry().contains(pos) or
|
||||
self.captionLabel.geometry().contains(pos)):
|
||||
self.close()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
"""
|
||||
Элемент карусели, реагирующий на клик.
|
||||
При клике открывается FullscreenDialog с возможностью перелистывания изображений.
|
||||
"""
|
||||
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
|
||||
"""
|
||||
:param pixmap: QPixmap для отображения в карусели
|
||||
:param caption: Подпись к изображению
|
||||
:param images_list: Список всех изображений (кортежей (QPixmap, caption)),
|
||||
чтобы в диалоге можно было перелистывать.
|
||||
Если не передан, будет использован только текущее изображение.
|
||||
:param index: Индекс текущего изображения в images_list.
|
||||
:param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
|
||||
"""
|
||||
super().__init__(pixmap)
|
||||
self.caption = caption
|
||||
self.images_list = images_list if images_list is not None else [(pixmap, caption)]
|
||||
self.index = index
|
||||
self.carousel = carousel
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setToolTip(caption)
|
||||
self._click_start_position = None
|
||||
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._click_start_position = event.scenePos()
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self._click_start_position is not None:
|
||||
distance = (event.scenePos() - self._click_start_position).manhattanLength()
|
||||
if distance < 2:
|
||||
self.show_fullscreen()
|
||||
event.accept()
|
||||
return
|
||||
event.accept()
|
||||
|
||||
def show_fullscreen(self):
|
||||
# Скрываем стрелки карусели перед открытием FullscreenDialog
|
||||
if self.carousel:
|
||||
self.carousel.prevArrow.hide()
|
||||
self.carousel.nextArrow.hide()
|
||||
dialog = FullscreenDialog(self.images_list, current_index=self.index)
|
||||
dialog.exec()
|
||||
# После закрытия диалога обновляем видимость стрелок
|
||||
if self.carousel:
|
||||
self.carousel.update_arrows_visibility()
|
||||
|
||||
|
||||
class ImageCarousel(QGraphicsView):
|
||||
"""
|
||||
Карусель изображений с адаптивностью, возможностью увеличения по клику
|
||||
и перетаскиванием мыши.
|
||||
"""
|
||||
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Аннотируем тип scene как QGraphicsScene
|
||||
self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
|
||||
self.setScene(self.carousel_scene)
|
||||
|
||||
self.images = images # Список кортежей: (QPixmap, caption)
|
||||
self.image_items = []
|
||||
self._animation = None
|
||||
self.theme = theme if theme else default_styles
|
||||
self.init_ui()
|
||||
self.create_arrows()
|
||||
|
||||
# Переменные для поддержки перетаскивания
|
||||
self._drag_active = False
|
||||
self._drag_start_position = None
|
||||
self._scroll_start_value = None
|
||||
|
||||
def init_ui(self):
|
||||
self.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
x_offset = 10 # Отступ между изображениями
|
||||
max_height = 300 # Фиксированная высота изображений
|
||||
x = 0
|
||||
|
||||
for i, (pixmap, caption) in enumerate(self.images):
|
||||
item = ClickablePixmapItem(
|
||||
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
|
||||
caption,
|
||||
images_list=self.images,
|
||||
index=i,
|
||||
carousel=self # Передаем ссылку на карусель
|
||||
)
|
||||
item.setPos(x, 0)
|
||||
self.carousel_scene.addItem(item)
|
||||
self.image_items.append(item)
|
||||
x += item.pixmap().width() + x_offset
|
||||
|
||||
self.setSceneRect(0, 0, x, max_height)
|
||||
|
||||
def create_arrows(self):
|
||||
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
|
||||
self.prevArrow = QToolButton(self)
|
||||
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
|
||||
self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore
|
||||
self.prevArrow.setFixedSize(40, 40)
|
||||
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.prevArrow.setAutoRepeat(True)
|
||||
self.prevArrow.setAutoRepeatDelay(300)
|
||||
self.prevArrow.setAutoRepeatInterval(100)
|
||||
self.prevArrow.clicked.connect(self.scroll_left)
|
||||
self.prevArrow.raise_()
|
||||
|
||||
self.nextArrow = QToolButton(self)
|
||||
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
|
||||
self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore
|
||||
self.nextArrow.setFixedSize(40, 40)
|
||||
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.nextArrow.setAutoRepeat(True)
|
||||
self.nextArrow.setAutoRepeatDelay(300)
|
||||
self.nextArrow.setAutoRepeatInterval(100)
|
||||
self.nextArrow.clicked.connect(self.scroll_right)
|
||||
self.nextArrow.raise_()
|
||||
|
||||
# Проверяем видимость стрелок при создании
|
||||
self.update_arrows_visibility()
|
||||
|
||||
def update_arrows_visibility(self):
|
||||
"""
|
||||
Показывает стрелки, если контент шире видимой области.
|
||||
Иначе скрывает их.
|
||||
"""
|
||||
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
|
||||
if self.horizontalScrollBar().maximum() == 0:
|
||||
self.prevArrow.hide()
|
||||
self.nextArrow.hide()
|
||||
else:
|
||||
self.prevArrow.show()
|
||||
self.nextArrow.show()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
margin = 10
|
||||
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
|
||||
self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
|
||||
(self.height() - self.nextArrow.height()) // 2)
|
||||
self.update_arrows_visibility()
|
||||
|
||||
def animate_scroll(self, end_value):
|
||||
scrollbar = self.horizontalScrollBar()
|
||||
start_value = scrollbar.value()
|
||||
animation = QPropertyAnimation(scrollbar, QByteArray(b"value"), self)
|
||||
animation.setDuration(300)
|
||||
animation.setStartValue(start_value)
|
||||
animation.setEndValue(end_value)
|
||||
animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
||||
self._animation = animation
|
||||
animation.start()
|
||||
|
||||
def scroll_left(self):
|
||||
scrollbar = self.horizontalScrollBar()
|
||||
new_value = scrollbar.value() - 100
|
||||
self.animate_scroll(new_value)
|
||||
|
||||
def scroll_right(self):
|
||||
scrollbar = self.horizontalScrollBar()
|
||||
new_value = scrollbar.value() + 100
|
||||
self.animate_scroll(new_value)
|
||||
|
||||
def update_images(self, new_images):
|
||||
self.carousel_scene.clear()
|
||||
self.images = new_images
|
||||
self.image_items.clear()
|
||||
self.init_ui()
|
||||
self.update_arrows_visibility()
|
||||
|
||||
# Обработка событий мыши для перетаскивания
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_active = True
|
||||
self._drag_start_position = event.pos()
|
||||
self._scroll_start_value = self.horizontalScrollBar().value()
|
||||
# Скрываем стрелки при начале перетаскивания
|
||||
if hasattr(self, "prevArrow"):
|
||||
self.prevArrow.hide()
|
||||
if hasattr(self, "nextArrow"):
|
||||
self.nextArrow.hide()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._drag_active and self._drag_start_position is not None:
|
||||
delta = event.pos().x() - self._drag_start_position.x()
|
||||
new_value = self._scroll_start_value - delta
|
||||
self.horizontalScrollBar().setValue(new_value)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._drag_active = False
|
||||
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
|
||||
self.update_arrows_visibility()
|
||||
super().mouseReleaseEvent(event)
|
430
portprotonqt/input_manager.py
Normal file
@ -0,0 +1,430 @@
|
||||
import time
|
||||
import threading
|
||||
from typing import Protocol, cast
|
||||
from evdev import InputDevice, ecodes, list_devices
|
||||
import pyudev
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit
|
||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.custom_widgets import NavLabel
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.config_utils import read_fullscreen_config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class MainWindowProtocol(Protocol):
|
||||
def activateFocusedWidget(self) -> None:
|
||||
...
|
||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||
...
|
||||
def switchTab(self, index: int) -> None:
|
||||
...
|
||||
def openAddGameDialog(self, exe_path: str | None = None) -> None:
|
||||
...
|
||||
def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
|
||||
...
|
||||
stackedWidget: QStackedWidget
|
||||
tabButtons: dict[int, QWidget]
|
||||
gamesListWidget: QWidget
|
||||
currentDetailPage: QWidget | None
|
||||
current_exec_line: str | None
|
||||
|
||||
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers (https://www.kernel.org/doc/html/v4.12/input/gamepad.html)
|
||||
BUTTONS = {
|
||||
# South button: X (PlayStation), A (Xbox), B (Switch Joy-Con south)
|
||||
'confirm': {ecodes.BTN_SOUTH, ecodes.BTN_A},
|
||||
# East button: Circle (PS), B (Xbox), A (Switch Joy-Con east)
|
||||
'back': {ecodes.BTN_EAST, ecodes.BTN_B},
|
||||
# North button: Triangle (PS), Y (Xbox), X (Switch Joy-Con north)
|
||||
'add_game': {ecodes.BTN_NORTH, ecodes.BTN_Y},
|
||||
# Shoulder buttons: L1/L2 (PS), LB (Xbox), L (Switch): BTN_TL, BTN_TL2
|
||||
'prev_tab': {ecodes.BTN_TL, ecodes.BTN_TL2},
|
||||
# Shoulder buttons: R1/R2 (PS), RB (Xbox), R (Switch): BTN_TR, BTN_TR2
|
||||
'next_tab': {ecodes.BTN_TR, ecodes.BTN_TR2},
|
||||
# Optional: stick presses on Switch Joy-Con
|
||||
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
|
||||
# Start button for context menu
|
||||
'context_menu': {ecodes.BTN_START},
|
||||
# Select/home for back/menu
|
||||
'menu': {ecodes.BTN_SELECT, ecodes.BTN_MODE},
|
||||
}
|
||||
|
||||
class InputManager(QObject):
|
||||
"""
|
||||
Manages input from gamepads and keyboards for navigating the application interface.
|
||||
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
||||
for seamless UI interaction.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
main_window: MainWindowProtocol,
|
||||
axis_deadzone: float = 0.5,
|
||||
initial_axis_move_delay: float = 0.3,
|
||||
repeat_axis_move_delay: float = 0.15
|
||||
):
|
||||
super().__init__(cast(QObject, main_window))
|
||||
self._parent = main_window
|
||||
# Ensure attributes exist on main_window
|
||||
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
|
||||
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
|
||||
|
||||
self.axis_deadzone = axis_deadzone
|
||||
self.initial_axis_move_delay = initial_axis_move_delay
|
||||
self.repeat_axis_move_delay = repeat_axis_move_delay
|
||||
self.current_axis_delay = initial_axis_move_delay
|
||||
self.last_move_time = 0.0
|
||||
self.axis_moving = False
|
||||
self.gamepad: InputDevice | None = None
|
||||
self.gamepad_thread: threading.Thread | None = None
|
||||
self.running = True
|
||||
self._is_fullscreen = read_fullscreen_config()
|
||||
|
||||
# Install keyboard event filter
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
else:
|
||||
logger.error("QApplication instance is None, cannot install event filter")
|
||||
|
||||
# Initialize evdev + hotplug
|
||||
self.init_gamepad()
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# 1) Интересуют только нажатия клавиш
|
||||
if not (isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress):
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
key = event.key()
|
||||
modifiers = event.modifiers()
|
||||
focused = QApplication.focusWidget()
|
||||
popup = QApplication.activePopupWidget()
|
||||
|
||||
# 2) Закрытие приложения по Ctrl+Q
|
||||
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||
app.quit()
|
||||
return True
|
||||
|
||||
# 3) Если открыт любой popup — не перехватываем ENTER, ESC и стрелки
|
||||
if popup:
|
||||
# возвращаем False, чтобы событие пошло дальше в Qt и закрыло popup как нужно
|
||||
return False
|
||||
|
||||
# 4) Навигация в полноэкранном просмотре
|
||||
active_win = QApplication.activeWindow()
|
||||
if isinstance(active_win, FullscreenDialog):
|
||||
if key == Qt.Key.Key_Right:
|
||||
active_win.show_next()
|
||||
return True
|
||||
if key == Qt.Key.Key_Left:
|
||||
active_win.show_prev()
|
||||
return True
|
||||
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
|
||||
active_win.close()
|
||||
return True
|
||||
|
||||
# 5) На странице деталей Enter запускает/останавливает игру
|
||||
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
if self._parent.current_exec_line:
|
||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||
return True
|
||||
|
||||
# 6) Открытие контекстного меню для GameCard
|
||||
if isinstance(focused, GameCard):
|
||||
if key == Qt.Key.Key_F10 and Qt.KeyboardModifier.ShiftModifier:
|
||||
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
||||
focused._show_context_menu(pos)
|
||||
return True
|
||||
|
||||
# 7) Навигация по карточкам в Library
|
||||
if self._parent.stackedWidget.currentIndex() == 0:
|
||||
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
|
||||
scroll_area = self._parent.gamesListWidget.parentWidget()
|
||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
||||
scroll_area = scroll_area.parentWidget()
|
||||
if not scroll_area:
|
||||
logger.warning("No QScrollArea found for gamesListWidget")
|
||||
|
||||
if isinstance(focused, GameCard):
|
||||
current_index = game_cards.index(focused) if focused in game_cards else -1
|
||||
if key == Qt.Key.Key_Down:
|
||||
if current_index >= 0 and current_index + 1 < len(game_cards):
|
||||
next_card = game_cards[current_index + 1]
|
||||
next_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||
return True
|
||||
elif key == Qt.Key.Key_Up:
|
||||
if current_index > 0:
|
||||
prev_card = game_cards[current_index - 1]
|
||||
prev_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(prev_card, 50, 50)
|
||||
return True
|
||||
elif current_index == 0:
|
||||
self._parent.tabButtons[0].setFocus()
|
||||
return True
|
||||
elif key == Qt.Key.Key_Left:
|
||||
if current_index > 0:
|
||||
prev_card = game_cards[current_index - 1]
|
||||
prev_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(prev_card, 50, 50)
|
||||
return True
|
||||
elif key == Qt.Key.Key_Right:
|
||||
if current_index >= 0 and current_index + 1 < len(game_cards):
|
||||
next_card = game_cards[current_index + 1]
|
||||
next_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||
return True
|
||||
|
||||
# 8) Переключение вкладок ←/→
|
||||
idx = self._parent.stackedWidget.currentIndex()
|
||||
total = len(self._parent.tabButtons)
|
||||
if key == Qt.Key.Key_Left and not isinstance(focused, GameCard):
|
||||
new = (idx - 1) % total
|
||||
self._parent.switchTab(new)
|
||||
self._parent.tabButtons[new].setFocus()
|
||||
return True
|
||||
if key == Qt.Key.Key_Right and not isinstance(focused, GameCard):
|
||||
new = (idx + 1) % total
|
||||
self._parent.switchTab(new)
|
||||
self._parent.tabButtons[new].setFocus()
|
||||
return True
|
||||
|
||||
# 9) Спуск в содержимое вкладки ↓
|
||||
if key == Qt.Key.Key_Down:
|
||||
if isinstance(focused, NavLabel):
|
||||
page = self._parent.stackedWidget.currentWidget()
|
||||
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
||||
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
||||
if focusables:
|
||||
focusables[0].setFocus()
|
||||
return True
|
||||
else:
|
||||
if focused is not None:
|
||||
focused.focusNextChild()
|
||||
return True
|
||||
|
||||
# 10) Подъём по содержимому вкладки ↑
|
||||
if key == Qt.Key.Key_Up:
|
||||
if isinstance(focused, NavLabel):
|
||||
return True # Не даём уйти выше NavLabel
|
||||
if focused is not None:
|
||||
focused.focusPreviousChild()
|
||||
return True
|
||||
|
||||
# 11) Общие: Activate, Back, Add
|
||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self._parent.activateFocusedWidget()
|
||||
return True
|
||||
elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Backspace):
|
||||
if isinstance(focused, QLineEdit):
|
||||
return False
|
||||
self._parent.goBackDetailPage(self._parent.currentDetailPage)
|
||||
return True
|
||||
elif key == Qt.Key.Key_E:
|
||||
if isinstance(focused, QLineEdit):
|
||||
return False
|
||||
self._parent.openAddGameDialog()
|
||||
return True
|
||||
|
||||
# 12) Переключение полноэкранного режима по F11
|
||||
if key == Qt.Key.Key_F11:
|
||||
if read_fullscreen_config():
|
||||
return True
|
||||
window = self._parent
|
||||
if isinstance(window, QWidget):
|
||||
if self._is_fullscreen:
|
||||
window.showNormal()
|
||||
self._is_fullscreen = False
|
||||
else:
|
||||
window.showFullScreen()
|
||||
self._is_fullscreen = True
|
||||
return True
|
||||
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def init_gamepad(self) -> None:
|
||||
self.check_gamepad()
|
||||
threading.Thread(target=self.run_udev_monitor, daemon=True).start()
|
||||
logger.info("Input support initialized with hotplug (evdev + pyudev)")
|
||||
|
||||
def run_udev_monitor(self) -> None:
|
||||
context = pyudev.Context()
|
||||
monitor = pyudev.Monitor.from_netlink(context)
|
||||
monitor.filter_by(subsystem='input')
|
||||
observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
|
||||
observer.start()
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
|
||||
def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
|
||||
if action == 'add':
|
||||
time.sleep(0.1)
|
||||
self.check_gamepad()
|
||||
elif action == 'remove' and self.gamepad:
|
||||
if not any(self.gamepad.path == path for path in list_devices()):
|
||||
logger.info("Gamepad disconnected")
|
||||
self.gamepad = None
|
||||
if self.gamepad_thread:
|
||||
self.gamepad_thread.join()
|
||||
|
||||
def check_gamepad(self) -> None:
|
||||
new_gamepad = self.find_gamepad()
|
||||
if new_gamepad and new_gamepad != self.gamepad:
|
||||
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
||||
self.gamepad = new_gamepad
|
||||
if self.gamepad_thread:
|
||||
self.gamepad_thread.join()
|
||||
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
|
||||
self.gamepad_thread.start()
|
||||
|
||||
def find_gamepad(self) -> InputDevice | None:
|
||||
devices = [InputDevice(path) for path in list_devices()]
|
||||
for device in devices:
|
||||
caps = device.capabilities()
|
||||
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
|
||||
return device
|
||||
return None
|
||||
|
||||
def monitor_gamepad(self) -> None:
|
||||
try:
|
||||
if not self.gamepad:
|
||||
return
|
||||
for event in self.gamepad.read_loop():
|
||||
if not self.running:
|
||||
break
|
||||
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
|
||||
continue
|
||||
now = time.time()
|
||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
||||
self.handle_button(event.code)
|
||||
elif event.type == ecodes.EV_ABS:
|
||||
self.handle_dpad(event.code, event.value, now)
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing gamepad: {e}")
|
||||
|
||||
def handle_button(self, button_code: int) -> None:
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
logger.error("QApplication instance is None")
|
||||
return
|
||||
active = QApplication.activeWindow()
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
# FullscreenDialog
|
||||
if isinstance(active, FullscreenDialog):
|
||||
if button_code in BUTTONS['prev_tab']:
|
||||
active.show_prev()
|
||||
elif button_code in BUTTONS['next_tab']:
|
||||
active.show_next()
|
||||
elif button_code in BUTTONS['back']:
|
||||
active.close()
|
||||
return
|
||||
|
||||
# Context menu for GameCard
|
||||
if isinstance(focused, GameCard):
|
||||
if button_code in BUTTONS['context_menu']:
|
||||
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
||||
focused._show_context_menu(pos)
|
||||
return
|
||||
|
||||
# Game launch on detail page
|
||||
if (button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']) and self._parent.currentDetailPage is not None:
|
||||
if self._parent.current_exec_line:
|
||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||
return
|
||||
|
||||
# Standard navigation
|
||||
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
|
||||
self._parent.activateFocusedWidget()
|
||||
elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
|
||||
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
|
||||
elif button_code in BUTTONS['add_game']:
|
||||
self._parent.openAddGameDialog()
|
||||
elif button_code in BUTTONS['prev_tab']:
|
||||
idx = (self._parent.stackedWidget.currentIndex() - 1) % len(self._parent.tabButtons)
|
||||
self._parent.switchTab(idx)
|
||||
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif button_code in BUTTONS['next_tab']:
|
||||
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
||||
self._parent.switchTab(idx)
|
||||
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def handle_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
logger.error("QApplication instance is None")
|
||||
return
|
||||
active = QApplication.activeWindow()
|
||||
|
||||
# Fullscreen horizontal
|
||||
if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X:
|
||||
if value < 0:
|
||||
active.show_prev()
|
||||
elif value > 0:
|
||||
active.show_next()
|
||||
return
|
||||
|
||||
# Vertical navigation (DPAD up/down)
|
||||
if code == ecodes.ABS_HAT0Y:
|
||||
# ignore release
|
||||
if value == 0:
|
||||
return
|
||||
focused = QApplication.focusWidget()
|
||||
page = self._parent.stackedWidget.currentWidget()
|
||||
if value > 0:
|
||||
# down
|
||||
if isinstance(focused, NavLabel):
|
||||
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
||||
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
||||
if focusables:
|
||||
focusables[0].setFocus()
|
||||
return
|
||||
elif focused:
|
||||
focused.focusNextChild()
|
||||
return
|
||||
elif value < 0 and focused:
|
||||
# up
|
||||
focused.focusPreviousChild()
|
||||
return
|
||||
|
||||
# Horizontal wrap navigation repeat logic
|
||||
if code != ecodes.ABS_HAT0X:
|
||||
return
|
||||
if value == 0:
|
||||
self.axis_moving = False
|
||||
self.current_axis_delay = self.initial_axis_move_delay
|
||||
return
|
||||
if not self.axis_moving:
|
||||
self.trigger_dpad_movement(code, value)
|
||||
self.last_move_time = current_time
|
||||
self.axis_moving = True
|
||||
elif current_time - self.last_move_time >= self.current_axis_delay:
|
||||
self.trigger_dpad_movement(code, value)
|
||||
self.last_move_time = current_time
|
||||
self.current_axis_delay = self.repeat_axis_move_delay
|
||||
|
||||
def trigger_dpad_movement(self, code: int, value: int) -> None:
|
||||
if code != ecodes.ABS_HAT0X:
|
||||
return
|
||||
idx = self._parent.stackedWidget.currentIndex()
|
||||
if value < 0:
|
||||
new = (idx - 1) % len(self._parent.tabButtons)
|
||||
else:
|
||||
new = (idx + 1) % len(self._parent.tabButtons)
|
||||
self._parent.switchTab(new)
|
||||
self._parent.tabButtons[new].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.running = False
|
||||
if self.gamepad:
|
||||
self.gamepad.close()
|
||||
logger.info("Input support cleaned up")
|
BIN
portprotonqt/locales/de_DE/LC_MESSAGES/messages.mo
Normal file
516
portprotonqt/locales/de_DE/LC_MESSAGES/messages.po
Normal file
@ -0,0 +1,516 @@
|
||||
# German (Germany) translations for PortProtonQT.
|
||||
# Copyright (C) 2025 boria138
|
||||
# This file is distributed under the same license as the PortProtonQT
|
||||
# project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
"Language-Team: de_DE <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command found in .desktop for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse .desktop file for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error reading .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ".desktop file not found for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable file not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable command: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
|
||||
" and custom data."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Could not locate .desktop file for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete custom data: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to menu: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from menu: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to desktop"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to desktop: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from Desktop: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove old .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Old .desktop file removed for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Restart Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The game was added successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The game was removed successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Opened folder for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to open game folder: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Browse..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Cover Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "No description available"
|
||||
msgstr ""
|
||||
|
||||
msgid "Never"
|
||||
msgstr ""
|
||||
|
||||
msgid "Supported"
|
||||
msgstr ""
|
||||
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Planned"
|
||||
msgstr ""
|
||||
|
||||
msgid "Broken"
|
||||
msgstr ""
|
||||
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Platinum"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gold"
|
||||
msgstr ""
|
||||
|
||||
msgid "Silver"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bronze"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading PortProton games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
msgstr ""
|
||||
|
||||
msgid "detailed"
|
||||
msgstr ""
|
||||
|
||||
msgid "brief"
|
||||
msgstr ""
|
||||
|
||||
msgid "Time Detail Level:"
|
||||
msgstr ""
|
||||
|
||||
msgid "last launch"
|
||||
msgstr ""
|
||||
|
||||
msgid "playtime"
|
||||
msgstr ""
|
||||
|
||||
msgid "alphabetical"
|
||||
msgstr ""
|
||||
|
||||
msgid "favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Games Sort Method:"
|
||||
msgstr ""
|
||||
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Username"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Username:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Password:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reset Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Cache cleared"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings saved"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Description:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Author:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Theme '{0}' applied successfully"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "PLAY TIME"
|
||||
msgstr ""
|
||||
|
||||
msgid "full"
|
||||
msgstr ""
|
||||
|
||||
msgid "partial"
|
||||
msgstr ""
|
||||
|
||||
msgid "none"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Gamepad Support: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Play"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (native)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (flatpak)"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launching"
|
||||
msgstr ""
|
||||
|
||||
msgid "just now"
|
||||
msgstr ""
|
||||
|
||||
msgid "d."
|
||||
msgstr ""
|
||||
|
||||
msgid "h."
|
||||
msgstr ""
|
||||
|
||||
msgid "min."
|
||||
msgstr ""
|
||||
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
BIN
portprotonqt/locales/es_ES/LC_MESSAGES/messages.mo
Normal file
516
portprotonqt/locales/es_ES/LC_MESSAGES/messages.po
Normal file
@ -0,0 +1,516 @@
|
||||
# Spanish (Spain) translations for PortProtonQT.
|
||||
# Copyright (C) 2025 boria138
|
||||
# This file is distributed under the same license as the PortProtonQT
|
||||
# project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
"Language-Team: es_ES <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command found in .desktop for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse .desktop file for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error reading .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ".desktop file not found for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable file not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable command: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
|
||||
" and custom data."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Could not locate .desktop file for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete custom data: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to menu: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from menu: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to desktop"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to desktop: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from Desktop: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove old .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Old .desktop file removed for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Restart Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The game was added successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The game was removed successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Opened folder for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to open game folder: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Browse..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Cover Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "No description available"
|
||||
msgstr ""
|
||||
|
||||
msgid "Never"
|
||||
msgstr ""
|
||||
|
||||
msgid "Supported"
|
||||
msgstr ""
|
||||
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Planned"
|
||||
msgstr ""
|
||||
|
||||
msgid "Broken"
|
||||
msgstr ""
|
||||
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Platinum"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gold"
|
||||
msgstr ""
|
||||
|
||||
msgid "Silver"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bronze"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading PortProton games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
msgstr ""
|
||||
|
||||
msgid "detailed"
|
||||
msgstr ""
|
||||
|
||||
msgid "brief"
|
||||
msgstr ""
|
||||
|
||||
msgid "Time Detail Level:"
|
||||
msgstr ""
|
||||
|
||||
msgid "last launch"
|
||||
msgstr ""
|
||||
|
||||
msgid "playtime"
|
||||
msgstr ""
|
||||
|
||||
msgid "alphabetical"
|
||||
msgstr ""
|
||||
|
||||
msgid "favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Games Sort Method:"
|
||||
msgstr ""
|
||||
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Username"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Username:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Password:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reset Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Cache cleared"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings saved"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Description:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Author:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Theme '{0}' applied successfully"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "PLAY TIME"
|
||||
msgstr ""
|
||||
|
||||
msgid "full"
|
||||
msgstr ""
|
||||
|
||||
msgid "partial"
|
||||
msgstr ""
|
||||
|
||||
msgid "none"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Gamepad Support: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Play"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (native)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (flatpak)"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launching"
|
||||
msgstr ""
|
||||
|
||||
msgid "just now"
|
||||
msgstr ""
|
||||
|
||||
msgid "d."
|
||||
msgstr ""
|
||||
|
||||
msgid "h."
|
||||
msgstr ""
|
||||
|
||||
msgid "min."
|
||||
msgstr ""
|
||||
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
514
portprotonqt/locales/messages.pot
Normal file
@ -0,0 +1,514 @@
|
||||
# Translations template for PortProtonQT.
|
||||
# Copyright (C) 2025 boria138
|
||||
# This file is distributed under the same license as the PortProtonQT
|
||||
# project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQT 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Menu"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command found in .desktop for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse .desktop file for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error reading .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ".desktop file not found for game: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable file not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable command: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
|
||||
" and custom data."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Could not locate .desktop file for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' deleted successfully"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete custom data: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to menu: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from menu: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to desktop"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to desktop: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from Desktop: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from Desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove old .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Old .desktop file removed for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Restart Steam"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The game was added successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"The game was removed successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Opened folder for '{0}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to open game folder: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Browse..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Cover Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "No description available"
|
||||
msgstr ""
|
||||
|
||||
msgid "Never"
|
||||
msgstr ""
|
||||
|
||||
msgid "Supported"
|
||||
msgstr ""
|
||||
|
||||
msgid "Running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Planned"
|
||||
msgstr ""
|
||||
|
||||
msgid "Broken"
|
||||
msgstr ""
|
||||
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Platinum"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gold"
|
||||
msgstr ""
|
||||
|
||||
msgid "Silver"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bronze"
|
||||
msgstr ""
|
||||
|
||||
msgid "Pending"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading PortProton games..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
msgstr ""
|
||||
|
||||
msgid "detailed"
|
||||
msgstr ""
|
||||
|
||||
msgid "brief"
|
||||
msgstr ""
|
||||
|
||||
msgid "Time Detail Level:"
|
||||
msgstr ""
|
||||
|
||||
msgid "last launch"
|
||||
msgstr ""
|
||||
|
||||
msgid "playtime"
|
||||
msgstr ""
|
||||
|
||||
msgid "alphabetical"
|
||||
msgstr ""
|
||||
|
||||
msgid "favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Games Sort Method:"
|
||||
msgstr ""
|
||||
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Username"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Username:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy Password:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reset Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Cache cleared"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings saved"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Description:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Author:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Theme '{0}' applied successfully"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "PLAY TIME"
|
||||
msgstr ""
|
||||
|
||||
msgid "full"
|
||||
msgstr ""
|
||||
|
||||
msgid "partial"
|
||||
msgstr ""
|
||||
|
||||
msgid "none"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Gamepad Support: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Play"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (native)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid command format (flatpak)"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launching"
|
||||
msgstr ""
|
||||
|
||||
msgid "just now"
|
||||
msgstr ""
|
||||
|
||||
msgid "d."
|
||||
msgstr ""
|
||||
|
||||
msgid "h."
|
||||
msgstr ""
|
||||
|
||||
msgid "min."
|
||||
msgstr ""
|
||||
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
BIN
portprotonqt/locales/ru_RU/LC_MESSAGES/messages.mo
Normal file
525
portprotonqt/locales/ru_RU/LC_MESSAGES/messages.po
Normal file
@ -0,0 +1,525 @@
|
||||
# Russian (Russia) translations for PortProtonQT.
|
||||
# Copyright (C) 2025 boria138
|
||||
# This file is distributed under the same license as the PortProtonQT
|
||||
# project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
|
||||
"PO-Revision-Date: 2025-05-29 17:42+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Удалить с рабочего стола"
|
||||
|
||||
msgid "Add to Desktop"
|
||||
msgstr "Добавить на рабочий стол"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Редактировать"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
msgid "Remove from Menu"
|
||||
msgstr "Удалить из меню"
|
||||
|
||||
msgid "Add to Menu"
|
||||
msgstr "Добавить в меню"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Удалить из Steam"
|
||||
|
||||
msgid "Add to Steam"
|
||||
msgstr "Добавить в Steam"
|
||||
|
||||
msgid "Error"
|
||||
msgstr "Ошибка"
|
||||
|
||||
msgid "PortProton is not found."
|
||||
msgstr "PortProton не найден."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command found in .desktop for game: {0}"
|
||||
msgstr "Не найдено ни одной исполняемой команды для игры: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse .desktop file for game: {0}"
|
||||
msgstr "Не удалось удалить файл .desktop: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error reading .desktop file: {0}"
|
||||
msgstr "Не удалось удалить файл .desktop: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ".desktop file not found for game: {0}"
|
||||
msgstr "Файл не найден: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {0}"
|
||||
msgstr "Недопустимая исполняемая команда: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable file not found: {0}"
|
||||
msgstr "Файл не найден: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable command: {0}"
|
||||
msgstr "Не удалось удалить игру из меню: {0}"
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr "Подтвердите удаление"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
|
||||
" and custom data."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить '{0}'? Это приведет к удалению файла "
|
||||
".desktop и настраиваемых данных."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Could not locate .desktop file for '{0}'"
|
||||
msgstr "Не удалось найти файл .desktop для '{0}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete .desktop file: {0}"
|
||||
msgstr "Не удалось удалить файл .desktop: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' deleted successfully"
|
||||
msgstr "Игра '{0}' успешно удалена"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete custom data: {0}"
|
||||
msgstr "Не удалось удалить настраиваемые данные: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to menu"
|
||||
msgstr "Игра '{0}' добавлена в меню"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to menu: {0}"
|
||||
msgstr "Не удалось добавить игру в меню: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from menu: {0}"
|
||||
msgstr "Не удалось удалить игру из меню: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from menu"
|
||||
msgstr "Игра '{0}' удалена из меню"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' added to desktop"
|
||||
msgstr "Игра '{0}' добавлена на рабочий стол"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add game to desktop: {0}"
|
||||
msgstr "Не удалось добавить игру на рабочий стол: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game from Desktop: {0}"
|
||||
msgstr "Не удалось удалить игру с рабочего стола: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Game '{0}' removed from Desktop"
|
||||
msgstr "Игра '{0}' удалена с рабочего стола"
|
||||
|
||||
msgid "Game name and executable path are required."
|
||||
msgstr "Необходимо указать название игры и путь к исполняемому файлу."
|
||||
|
||||
msgid "Failed to generate .desktop file data."
|
||||
msgstr "Не удалось сгенерировать данные файла .desktop."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove old .desktop file: {0}"
|
||||
msgstr "Не удалось удалить файл .desktop: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Old .desktop file removed for '{0}'"
|
||||
msgstr "Старый файл .desktop удален для '{0}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {0}"
|
||||
msgstr "Не удалось удалить файл .desktop: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {0}"
|
||||
msgstr "Не удалось удалить игру из меню: {0}"
|
||||
|
||||
msgid "Restart Steam"
|
||||
msgstr "Перезапустите Steam"
|
||||
|
||||
msgid ""
|
||||
"The game was added successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
"Игра была успешно добавлена.\n"
|
||||
"Пожалуйста, перезапустите Steam, чтобы изменения вступили в силу."
|
||||
|
||||
msgid ""
|
||||
"The game was removed successfully.\n"
|
||||
"Please restart Steam for changes to take effect."
|
||||
msgstr ""
|
||||
"Игра была успешно удалена..\n"
|
||||
"Пожалуйста, перезапустите Steam, чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Opened folder for '{0}'"
|
||||
msgstr "Открытие папки для '{0}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to open game folder: {0}"
|
||||
msgstr "Не удалось открыть папку для игры: {0}"
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr "Редактировать игру"
|
||||
|
||||
msgid "Add Game"
|
||||
msgstr "Добавить игру"
|
||||
|
||||
msgid "Game Name:"
|
||||
msgstr "Имя игры:"
|
||||
|
||||
msgid "Browse..."
|
||||
msgstr "Обзор..."
|
||||
|
||||
msgid "Path to Executable:"
|
||||
msgstr "Путь к исполняемому файлу:"
|
||||
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Обложка:"
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr "Предпросмотр обложки:"
|
||||
|
||||
msgid "Select Executable"
|
||||
msgstr "Выберите исполняемый файл"
|
||||
|
||||
msgid "Select Cover Image"
|
||||
msgstr "Выберите обложку"
|
||||
|
||||
msgid "Invalid image"
|
||||
msgstr "Недопустимое изображение"
|
||||
|
||||
msgid "No cover selected"
|
||||
msgstr "Обложка не выбрана"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr "Запустить игру \"{name}\" с помощью PortProton"
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr "Загрузка игр из Epic Games Store..."
|
||||
|
||||
msgid "No description available"
|
||||
msgstr "Описание не найдено"
|
||||
|
||||
msgid "Never"
|
||||
msgstr "Никогда"
|
||||
|
||||
msgid "Supported"
|
||||
msgstr "Поддерживается"
|
||||
|
||||
msgid "Running"
|
||||
msgstr "Запускается"
|
||||
|
||||
msgid "Planned"
|
||||
msgstr "Планируется"
|
||||
|
||||
msgid "Broken"
|
||||
msgstr "Сломано"
|
||||
|
||||
msgid "Denied"
|
||||
msgstr "Отказано"
|
||||
|
||||
msgid "Platinum"
|
||||
msgstr "Платина"
|
||||
|
||||
msgid "Gold"
|
||||
msgstr "Золото"
|
||||
|
||||
msgid "Silver"
|
||||
msgstr "Серебро"
|
||||
|
||||
msgid "Bronze"
|
||||
msgstr "Бронза"
|
||||
|
||||
msgid "Pending"
|
||||
msgstr "В ожидании"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Автоустановка"
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr "Эмуляторы"
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr "Настройки wine"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProton"
|
||||
|
||||
msgid "Themes"
|
||||
msgstr "Темы"
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr "Загрузка игр из Steam..."
|
||||
|
||||
msgid "Loading PortProton games..."
|
||||
msgstr "Загрузка игр из PortProton..."
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Неизвестная игра"
|
||||
|
||||
msgid "Game Library"
|
||||
msgstr "Игровая библиотека"
|
||||
|
||||
msgid "Find Games ..."
|
||||
msgstr "Найти игры..."
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgstr "Здесь можно настроить автоматическую установку игр..."
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgstr "Список доступных эмуляторов и их настройка..."
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgstr "Различные параметры и версии wine..."
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
msgstr "Основные параметры PortProton..."
|
||||
|
||||
msgid "detailed"
|
||||
msgstr "детальный"
|
||||
|
||||
msgid "brief"
|
||||
msgstr "упрощённый"
|
||||
|
||||
msgid "Time Detail Level:"
|
||||
msgstr "Уровень детализации вывода времени:"
|
||||
|
||||
msgid "last launch"
|
||||
msgstr "последний запуск"
|
||||
|
||||
msgid "playtime"
|
||||
msgstr "время игры"
|
||||
|
||||
msgid "alphabetical"
|
||||
msgstr "алфавитный"
|
||||
|
||||
msgid "favorites"
|
||||
msgstr "избранное"
|
||||
|
||||
msgid "Games Sort Method:"
|
||||
msgstr "Метод сортировки игр:"
|
||||
|
||||
msgid "all"
|
||||
msgstr "все"
|
||||
|
||||
msgid "Games Display Filter:"
|
||||
msgstr "Фильтр игр:"
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr "Адрес прокси"
|
||||
|
||||
msgid "Proxy URL:"
|
||||
msgstr "Адрес прокси:"
|
||||
|
||||
msgid "Proxy Username"
|
||||
msgstr "Имя пользователя прокси"
|
||||
|
||||
msgid "Proxy Username:"
|
||||
msgstr "Имя пользователя прокси:"
|
||||
|
||||
msgid "Proxy Password"
|
||||
msgstr "Пароль прокси"
|
||||
|
||||
msgid "Proxy Password:"
|
||||
msgstr "Пароль прокси:"
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Запуск приложения в полноэкранном режиме"
|
||||
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr "Режим полноэкранного отображения приложения:"
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr "Открыть браузер для входа в Legendary"
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr "Авторизация в Legendary:"
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr "Введите код авторизации Legendary"
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr "Код авторизации:"
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr "Отправить код"
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr "Сохранить настройки"
|
||||
|
||||
msgid "Reset Settings"
|
||||
msgstr "Сбросить настройки"
|
||||
|
||||
msgid "Clear Cache"
|
||||
msgstr "Очистить кэш"
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr "Открытие страницы входа в Legendary в браузере"
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr "Не удалось открыть страницу входа в Legendary"
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr "Пожалуйста, введите код авторизации"
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr "Успешная аутентификация с Legendary"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr "Сбой аутентификации в Legendary: {0}"
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr "Не найден исполняемый файл Legendary"
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr "Неожиданная ошибка при аутентификации"
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr "Подтвердите удаление"
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr "Настройки сброшены. Перезапуск..."
|
||||
|
||||
msgid "Confirm Clear Cache"
|
||||
msgstr "Подтвердите очистку кэша"
|
||||
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr "Вы уверены, что хотите очистить кэш? Это действие нельзя отменить."
|
||||
|
||||
msgid "Cache cleared"
|
||||
msgstr "Кэш очищен"
|
||||
|
||||
msgid "Settings saved"
|
||||
msgstr "Настройки сохранены"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
msgid "Apply Theme"
|
||||
msgstr "Применить тему"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Нет ссылки"
|
||||
|
||||
msgid "Unknown"
|
||||
msgstr "Неизвестен"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
|
||||
msgid "Description:"
|
||||
msgstr "Описание:"
|
||||
|
||||
msgid "Author:"
|
||||
msgstr "Автор:"
|
||||
|
||||
msgid "Link:"
|
||||
msgstr "Ссылка:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Theme '{0}' applied successfully"
|
||||
msgstr "Тема '{0}' применена успешно"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr "Ошибка при применение темы '{0}'"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "Последний запуск"
|
||||
|
||||
msgid "PLAY TIME"
|
||||
msgstr "Время игры"
|
||||
|
||||
msgid "full"
|
||||
msgstr "полная"
|
||||
|
||||
msgid "partial"
|
||||
msgstr "частичная"
|
||||
|
||||
msgid "none"
|
||||
msgstr "отсутствует"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Gamepad Support: {0}"
|
||||
msgstr "Поддержка геймпадов: {0}"
|
||||
|
||||
msgid "Stop"
|
||||
msgstr "Остановить"
|
||||
|
||||
msgid "Play"
|
||||
msgstr "Играть"
|
||||
|
||||
msgid "Invalid command format (native)"
|
||||
msgstr "Неправильный формат команды (нативная версия)"
|
||||
|
||||
msgid "Invalid command format (flatpak)"
|
||||
msgstr "Неправильный формат команды (flatpak)"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Файл не найден: {0}"
|
||||
|
||||
msgid "Cannot launch game while another game is running"
|
||||
msgstr "Невозможно запустить игру пока запущена другая"
|
||||
|
||||
msgid "Launching"
|
||||
msgstr "Идёт запуск"
|
||||
|
||||
msgid "just now"
|
||||
msgstr "только что"
|
||||
|
||||
msgid "d."
|
||||
msgstr "д."
|
||||
|
||||
msgid "h."
|
||||
msgstr "ч."
|
||||
|
||||
msgid "min."
|
||||
msgstr "мин."
|
||||
|
||||
msgid "sec."
|
||||
msgstr "сек."
|
||||
|
74
portprotonqt/localization.py
Normal file
@ -0,0 +1,74 @@
|
||||
import gettext
|
||||
from pathlib import Path
|
||||
import locale
|
||||
from babel import Locale
|
||||
|
||||
LOCALE_MAP = {
|
||||
'ru': 'russian',
|
||||
'en': 'english',
|
||||
'fr': 'french',
|
||||
'de': 'german',
|
||||
'es': 'spanish',
|
||||
'it': 'italian',
|
||||
'zh': 'schinese',
|
||||
'zh_Hant': 'tchinese',
|
||||
'ja': 'japanese',
|
||||
'ko': 'koreana',
|
||||
'pt': 'brazilian',
|
||||
'pl': 'polish',
|
||||
'nl': 'dutch',
|
||||
'sv': 'swedish',
|
||||
'no': 'norwegian',
|
||||
'da': 'danish',
|
||||
'fi': 'finnish',
|
||||
'cs': 'czech',
|
||||
'hu': 'hungarian',
|
||||
'tr': 'turkish',
|
||||
'ro': 'romanian',
|
||||
'th': 'thai',
|
||||
'uk': 'ukrainian',
|
||||
'bg': 'bulgarian',
|
||||
'el': 'greek',
|
||||
}
|
||||
|
||||
translate = gettext.translation(
|
||||
domain="messages",
|
||||
localedir = Path(__file__).parent / "locales",
|
||||
fallback=True,
|
||||
)
|
||||
_ = translate.gettext
|
||||
|
||||
def get_system_locale():
|
||||
"""Возвращает системную локаль, например, 'ru_RU'. Если не удаётся определить – возвращает 'en'."""
|
||||
loc = locale.getdefaultlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
def get_steam_language():
|
||||
try:
|
||||
# Babel автоматически разбирает сложные локали, например, 'zh_Hant_HK' → 'zh_Hant'
|
||||
system_locale = get_system_locale()
|
||||
if system_locale:
|
||||
locale = Locale.parse(system_locale)
|
||||
# Используем только языковой код ('ru', 'en', и т.д.)
|
||||
language_code = locale.language
|
||||
return LOCALE_MAP.get(language_code, 'english')
|
||||
except Exception as e:
|
||||
print(f"Failed to detect locale: {e}")
|
||||
|
||||
# Если что-то пошло не так — используем английский по умолчанию
|
||||
return 'english'
|
||||
|
||||
def get_egs_language():
|
||||
try:
|
||||
# Babel автоматически разбирает сложные локали, например, 'zh_Hant_HK' → 'zh_Hant'
|
||||
system_locale = get_system_locale()
|
||||
if system_locale:
|
||||
locale = Locale.parse(system_locale)
|
||||
# Используем только языковой код ('ru', 'en', и т.д.)
|
||||
language_code = locale.language
|
||||
return language_code
|
||||
except Exception as e:
|
||||
print(f"Failed to detect locale: {e}")
|
||||
|
||||
# Если что-то пошло не так — используем английский по умолчанию
|
||||
return 'en'
|
16
portprotonqt/logger.py
Normal file
@ -0,0 +1,16 @@
|
||||
import logging
|
||||
|
||||
def setup_logger():
|
||||
"""Настройка базовой конфигурации логирования."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(levelname)s] %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
|
||||
def get_logger(name):
|
||||
"""Возвращает логгер для указанного модуля."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
# Инициализация логгера при импорте модуля
|
||||
setup_logger()
|
1684
portprotonqt/main_window.py
Normal file
1134
portprotonqt/steam_api.py
Normal file
286
portprotonqt/theme_manager.py
Normal file
@ -0,0 +1,286 @@
|
||||
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
|
BIN
portprotonqt/themes/standart-light/fonts/Orbitron-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart-light/fonts/Poppins-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart-light/fonts/RASKHAL-Regular.ttf
Executable file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 734 B |
1
portprotonqt/themes/standart-light/images/icons/back.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 213 B |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
After Width: | Height: | Size: 622 B |
1
portprotonqt/themes/standart-light/images/icons/down.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 164 B |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
After Width: | Height: | Size: 570 B |
1
portprotonqt/themes/standart-light/images/icons/play.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 367 B |
@ -0,0 +1 @@
|
||||
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>
|
After Width: | Height: | Size: 2.3 KiB |
1
portprotonqt/themes/standart-light/images/icons/save.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>
|
After Width: | Height: | Size: 392 B |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 660 B |
After Width: | Height: | Size: 7.9 KiB |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
portprotonqt/themes/standart-light/images/icons/stop.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 208 B |
1
portprotonqt/themes/standart-light/images/icons/up.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 165 B |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848t-1.5848 3.8596q-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z"/></svg>
|
After Width: | Height: | Size: 717 B |
BIN
portprotonqt/themes/standart-light/images/placeholder.jpg
Normal file
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.6 MiB |
After Width: | Height: | Size: 475 KiB |
After Width: | Height: | Size: 151 KiB |
5
portprotonqt/themes/standart-light/metainfo.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[Metainfo]
|
||||
author = BlackSnaker
|
||||
author_link =
|
||||
description = Стандартная тема PortProtonQT (светлый вариант)
|
||||
name = Light
|
558
portprotonqt/themes/standart-light/styles.py
Normal file
@ -0,0 +1,558 @@
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
# КОНСТАНТЫ
|
||||
favoriteLabelSize = 48, 48
|
||||
pixmapsScaledSize = 60, 60
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = """
|
||||
QFrame {
|
||||
background: transparent;
|
||||
border: 10px solid rgba(255, 255, 255, 0.10);
|
||||
border-bottom: 0px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-left-radius: 30px;
|
||||
border-top-right-radius: 30px;
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
|
||||
TITLE_LABEL_STYLE = """
|
||||
QLabel {
|
||||
font-family: 'RASKHAL';
|
||||
font-size: 38px;
|
||||
margin: 0 0 0 0;
|
||||
color: #007AFF;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||
NAV_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: #ffffff;
|
||||
border-bottom: 0px solid rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
|
||||
NAV_BUTTON_STYLE = """
|
||||
NavLabel {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(242, 242, 242, 0.5),
|
||||
stop:1 rgba(232, 232, 232, 0.5));
|
||||
padding: 10px 10px;
|
||||
margin: 10px 0 10px 10px;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-family: 'Poppins';
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(179, 179, 179, 0.4);
|
||||
border-radius: 15px;
|
||||
}
|
||||
NavLabel[checked = true] {
|
||||
background: rgba(0,122,255,0.25);
|
||||
color: #002244;
|
||||
font-weight: bold;
|
||||
border-radius: 15px;
|
||||
}
|
||||
NavLabel:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(0,122,255,0.12),
|
||||
stop:1 rgba(0,122,255,0.08));
|
||||
color: #002244;
|
||||
}
|
||||
"""
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
|
||||
MAIN_WINDOW_STYLE = """
|
||||
QMainWindow {
|
||||
background: none;
|
||||
}
|
||||
QLabel {
|
||||
color: #333333;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПОЛЯ ПОИСКА
|
||||
SEARCH_EDIT_STYLE = """
|
||||
QLineEdit {
|
||||
background-color: rgba(30, 30, 30, 0.50);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
padding: 7px 14px;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
|
||||
SCROLL_AREA_STYLE = """
|
||||
QWidget {
|
||||
background: transparent;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
width: 10px;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollBar::add-line:vertical {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::sub-line:vertical {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
|
||||
border: 0px solid;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar:horizontal {
|
||||
height: 10px;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollBar::add-line:horizontal {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::sub-line:horizontal {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
|
||||
border: 0px solid;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# SLIDER_SIZE_STYLE
|
||||
SLIDER_SIZE_STYLE= """
|
||||
QWidget {
|
||||
background: transparent;
|
||||
height: 25px;
|
||||
}
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px solid;
|
||||
border-radius: 3px;
|
||||
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
margin: 6px 0;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
|
||||
border-radius: 9px;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
|
||||
LIST_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: none;
|
||||
border: 0px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 25px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
|
||||
INSTALLED_TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627;"
|
||||
|
||||
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
|
||||
ACTION_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(242, 242, 242, 0.5),
|
||||
stop:1 rgba(232, 232, 232, 0.5));
|
||||
border: 1px solid rgba(179, 179, 179, 0.4);
|
||||
border-radius: 10px;
|
||||
color: #232627;
|
||||
font-size: 16px;
|
||||
font-family: 'Poppins';
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
|
||||
TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627; background-color: none;"
|
||||
CONTENT_STYLE = """
|
||||
QLabel {
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
color: #232627;
|
||||
background-color: none;
|
||||
border-bottom: 1px solid rgba(165, 165, 165, 0.7);
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
|
||||
# LIBRARY_WIDGET_STYLE
|
||||
LIBRARY_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CONTAINER_STYLE
|
||||
CONTAINER_STYLE= """
|
||||
QWidget {
|
||||
background-color: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# OTHER_PAGES_WIDGET_STYLE
|
||||
OTHER_PAGES_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CAROUSEL_WIDGET_STYLE
|
||||
CAROUSEL_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(spread:pad, x1:0.099, y1:0.119, x2:0.917, y2:0.936149, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(217, 193, 255, 255));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
|
||||
DETAIL_PAGE_NO_COVER_STYLE = "background: rgba(20,20,20,0.95); border-radius: 15px;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
|
||||
ADDGAME_BACK_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-family: 'Poppins';
|
||||
padding: 4px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
|
||||
DETAIL_CONTENT_FRAME_STYLE = """
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(20, 20, 20, 0.40),
|
||||
stop:1 rgba(20, 20, 20, 0.35));
|
||||
border: 0px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 15px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ФРЕЙМ ПОД ОБЛОЖКОЙ
|
||||
COVER_FRAME_STYLE = """
|
||||
QFrame {
|
||||
background: rgba(30, 30, 30, 0.80);
|
||||
border-radius: 15px;
|
||||
border: 0px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
"""
|
||||
|
||||
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
|
||||
COVER_LABEL_STYLE = "border-radius: 100px;"
|
||||
|
||||
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
|
||||
DETAILS_WIDGET_STYLE = "background: rgba(20,20,20,0.40); border-radius: 15px; padding: 10px;"
|
||||
|
||||
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
|
||||
DETAIL_PAGE_TITLE_STYLE = "font-family: 'Orbitron'; font-size: 32px; color: #007AFF;"
|
||||
|
||||
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
|
||||
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
|
||||
|
||||
# ТЕКСТ ОПИСАНИЯ
|
||||
DETAIL_PAGE_DESC_STYLE = "font-family: 'Poppins'; font-size: 16px; color: #ffffff; line-height: 1.5;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ИГРАТЬ"
|
||||
PLAY_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-family: 'Orbitron';
|
||||
padding: 8px 16px;
|
||||
min-width: 120px;
|
||||
min-height: 40px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
|
||||
DIALOG_BROWSE_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 0px solid rgba(255, 255, 255, 0.20);
|
||||
border-radius: 15px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(0,122,255,0.20),
|
||||
stop:1 rgba(0,122,255,0.15));
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(20, 20, 20, 0.60);
|
||||
border: 0px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
||||
GAME_CARD_WINDOW_STYLE = """
|
||||
QFrame {
|
||||
border-radius: 20px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 rgba(255, 255, 255, 0.3),
|
||||
stop:1 rgba(249, 249, 249, 0.3));
|
||||
border: 0px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
"""
|
||||
|
||||
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
|
||||
GAME_CARD_NAME_LABEL_STYLE = """
|
||||
QLabel {
|
||||
color: #333333;
|
||||
font-family: 'Orbitron';
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(242, 242, 242, 0.5),
|
||||
stop:1 rgba(232, 232, 232, 0.5));
|
||||
border-radius: 20px;
|
||||
padding: 7px;
|
||||
qproperty-wordWrap: true;
|
||||
}
|
||||
"""
|
||||
|
||||
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
|
||||
LAST_LAUNCH_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
LAST_LAUNCH_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
|
||||
PLAY_TIME_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
PLAY_TIME_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
|
||||
GAMEPAD_SUPPORT_VALUE_STYLE = """
|
||||
font-family: 'Poppins'; font-size: 12px; color: #00ff00;
|
||||
font-weight: bold; background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 5px; padding: 4px 8px;
|
||||
"""
|
||||
|
||||
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
|
||||
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
CAPTION_LABEL_STYLE="color: white; font-size: 16px;"
|
||||
|
||||
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
|
||||
def get_protondb_badge_style(tier):
|
||||
tier = tier.lower()
|
||||
tier_colors = {
|
||||
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
|
||||
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
|
||||
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
|
||||
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
|
||||
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
|
||||
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
|
||||
}
|
||||
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
font-size: 14px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Poppins';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА STEAM
|
||||
STEAM_BADGE_STYLE= """
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Poppins';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# Favorite Star
|
||||
FAVORITE_LABEL_STYLE = "color: gold; font-size: 32px; background: transparent; border: none;"
|
||||
|
||||
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
|
||||
MESSAGE_BOX_STYLE = """
|
||||
QMessageBox {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(40, 40, 40, 0.95),
|
||||
stop:1 rgba(25, 25, 25, 0.95));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
padding: 8px 20px;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background: #09bec8;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
|
||||
# PARAMS_TITLE_STYLE
|
||||
PARAMS_TITLE_STYLE = "color: #232627; font-family: 'Poppins'; font-size: 16px; padding: 10px; background: transparent;"
|
||||
|
||||
PROXY_INPUT_STYLE = """
|
||||
QLineEdit {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 0px solid rgba(165, 165, 165, 0.7);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid rgba(0,122,255,0.25);
|
||||
}
|
||||
QMenu {
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
padding: 5px 10px;
|
||||
background: #c7c7c7;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 0px 10px;
|
||||
border: 10px solid transparent; /* reserve space for selection border */
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
"""
|
||||
|
||||
SETTINGS_COMBO_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.5);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
border-top-style: none;
|
||||
}}
|
||||
QListView {{
|
||||
background: #ffffff;
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
border-radius: 0px;
|
||||
color: #232627;
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: rgba(0,122,255,0.25);
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: rgba(0,122,255,0.25);
|
||||
}}
|
||||
"""
|
BIN
portprotonqt/themes/standart/fonts/Orbitron-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart/fonts/Play-Bold.ttf
Normal file
BIN
portprotonqt/themes/standart/fonts/Play-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart/fonts/RASKHAL-Regular.ttf
Executable file
1
portprotonqt/themes/standart/images/icons/addgame.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 734 B |
1
portprotonqt/themes/standart/images/icons/back.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 213 B |
1
portprotonqt/themes/standart/images/icons/broken.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
After Width: | Height: | Size: 622 B |
1
portprotonqt/themes/standart/images/icons/down.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#b3b3b3"/></svg>
|
After Width: | Height: | Size: 167 B |
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
After Width: | Height: | Size: 570 B |
1
portprotonqt/themes/standart/images/icons/play.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 367 B |
1
portprotonqt/themes/standart/images/icons/ppqt-tray.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 2.3 KiB |
1
portprotonqt/themes/standart/images/icons/save.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 404 B |
1
portprotonqt/themes/standart/images/icons/search.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 660 B |
After Width: | Height: | Size: 7.9 KiB |
1
portprotonqt/themes/standart/images/icons/spinner.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
1
portprotonqt/themes/standart/images/icons/steam.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
portprotonqt/themes/standart/images/icons/stop.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 208 B |
1
portprotonqt/themes/standart/images/icons/up.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#b3b3b3"/></svg>
|
After Width: | Height: | Size: 168 B |
1
portprotonqt/themes/standart/images/icons/update.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848-1.5846 1.5848-1.5848 3.8596-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 741 B |
BIN
portprotonqt/themes/standart/images/placeholder.jpg
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/Библиотека.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
portprotonqt/themes/standart/images/screenshots/Карточка.png
Normal file
After Width: | Height: | Size: 621 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/Настройки.png
Normal file
After Width: | Height: | Size: 73 KiB |
1
portprotonqt/themes/standart/images/theme_logo.svg
Normal file
After Width: | Height: | Size: 7.8 KiB |
5
portprotonqt/themes/standart/metainfo.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[Metainfo]
|
||||
author = Dervart
|
||||
author_link =
|
||||
description = Стандартная тема PortProtonQT (тёмный вариант)
|
||||
name = Clean Dark
|
576
portprotonqt/themes/standart/styles.py
Normal file
@ -0,0 +1,576 @@
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
# КОНСТАНТЫ
|
||||
favoriteLabelSize = 48, 48
|
||||
pixmapsScaledSize = 60, 60
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = """
|
||||
QFrame {
|
||||
background: transparent;
|
||||
border: 10px solid rgba(255, 255, 255, 0.10);
|
||||
border-bottom: 0px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-left-radius: 30px;
|
||||
border-top-right-radius: 30px;
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
|
||||
TITLE_LABEL_STYLE = """
|
||||
QLabel {
|
||||
font-family: 'RASKHAL';
|
||||
font-size: 38px;
|
||||
margin: 0 0 0 0;
|
||||
color: #007AFF;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||
NAV_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: none;
|
||||
border: 0px solid;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
|
||||
NAV_BUTTON_STYLE = """
|
||||
NavLabel {
|
||||
background: rgba(0,122,255,0);
|
||||
padding: 12px 3px;
|
||||
margin: 10px 0 10px 10px;
|
||||
color: #7f7f7f;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
}
|
||||
NavLabel[checked = true] {
|
||||
background: rgba(0,122,255,0);
|
||||
color: #09bec8;
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
border-radius: 15px;
|
||||
}
|
||||
NavLabel:hover {
|
||||
background: none;
|
||||
color: #09bec8;
|
||||
}
|
||||
"""
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
|
||||
MAIN_WINDOW_STYLE = """
|
||||
QMainWindow {
|
||||
background: none;
|
||||
}
|
||||
QLabel {
|
||||
color: #232627;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПОЛЯ ПОИСКА
|
||||
SEARCH_EDIT_STYLE = """
|
||||
QLineEdit {
|
||||
background-color: rgba(30, 30, 30, 0.50);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
padding: 7px 14px;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid #09bec8;
|
||||
}
|
||||
"""
|
||||
|
||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
|
||||
SCROLL_AREA_STYLE = """
|
||||
QWidget {
|
||||
background: transparent;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
width: 10px;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollBar::add-line:vertical {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::sub-line:vertical {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
|
||||
border: 0px solid;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
height: 10px;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollBar::add-line:horizontal {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::sub-line:horizontal {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
|
||||
border: 0px solid;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# SLIDER_SIZE_STYLE
|
||||
SLIDER_SIZE_STYLE= """
|
||||
QWidget {
|
||||
background: transparent;
|
||||
height: 25px;
|
||||
}
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px solid;
|
||||
border-radius: 3px;
|
||||
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
margin: 6px 0;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
|
||||
border-radius: 9px;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
|
||||
LIST_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: none;
|
||||
border: 0px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 25px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
|
||||
INSTALLED_TAB_TITLE_STYLE = "font-family: 'Play'; font-size: 24px; color: #ffffff;"
|
||||
|
||||
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
|
||||
ACTION_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: #3f424d;
|
||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-family: 'Play';
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #282a33;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #282a33;
|
||||
}
|
||||
"""
|
||||
|
||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
|
||||
TAB_TITLE_STYLE = "font-family: 'Play'; font-size: 24px; color: #ffffff; background-color: none;"
|
||||
CONTENT_STYLE = """
|
||||
QLabel {
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
background-color: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
|
||||
# LIBRARY_WIDGET_STYLE
|
||||
LIBRARY_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(112,20,132,1),
|
||||
stop:1 rgba(50,134,182,1));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CONTAINER_STYLE
|
||||
CONTAINER_STYLE= """
|
||||
QWidget {
|
||||
background-color: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# OTHER_PAGES_WIDGET_STYLE
|
||||
OTHER_PAGES_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: #32343d;
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CAROUSEL_WIDGET_STYLE
|
||||
CAROUSEL_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: #3f424d;
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
|
||||
DETAIL_PAGE_NO_COVER_STYLE = "background: rgba(20,20,20,0.95); border-radius: 15px;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
|
||||
ADDGAME_BACK_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-family: 'Play';
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #09bec8;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #09bec8;
|
||||
}
|
||||
"""
|
||||
|
||||
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
|
||||
DETAIL_CONTENT_FRAME_STYLE = """
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(20, 20, 20, 0.40),
|
||||
stop:1 rgba(20, 20, 20, 0.35));
|
||||
border: 0px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 15px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ФРЕЙМ ПОД ОБЛОЖКОЙ
|
||||
COVER_FRAME_STYLE = """
|
||||
QFrame {
|
||||
background: rgba(30, 30, 30, 0.80);
|
||||
border-radius: 15px;
|
||||
border: 0px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
"""
|
||||
|
||||
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
|
||||
COVER_LABEL_STYLE = "border-radius: 100px;"
|
||||
|
||||
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
|
||||
DETAILS_WIDGET_STYLE = "background: rgba(20,20,20,0.40); border-radius: 15px; padding: 10px;"
|
||||
|
||||
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
|
||||
DETAIL_PAGE_TITLE_STYLE = "font-family: 'Play'; font-size: 32px; color: #007AFF;"
|
||||
|
||||
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
|
||||
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
|
||||
|
||||
# ТЕКСТ ОПИСАНИЯ
|
||||
DETAIL_PAGE_DESC_STYLE = "font-family: 'Play'; font-size: 16px; color: #ffffff; line-height: 1.5;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ИГРАТЬ"
|
||||
PLAY_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-family: 'Play';
|
||||
padding: 8px 16px;
|
||||
min-width: 120px;
|
||||
min-height: 40px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #09bec8;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #09bec8;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
|
||||
DIALOG_BROWSE_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 0px solid rgba(255, 255, 255, 0.20);
|
||||
border-radius: 15px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(0,122,255,0.20),
|
||||
stop:1 rgba(0,122,255,0.15));
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(20, 20, 20, 0.60);
|
||||
border: 0px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
||||
GAME_CARD_WINDOW_STYLE = """
|
||||
QFrame {
|
||||
border-radius: 20px;
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 0px solid rgba(255, 255, 255, 0.20);
|
||||
}
|
||||
"""
|
||||
|
||||
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
|
||||
GAME_CARD_NAME_LABEL_STYLE = """
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
background-color: rgba(20, 20, 20, 0);
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
padding: 14px, 7px, 3px, 7px;
|
||||
qproperty-wordWrap: true;
|
||||
}
|
||||
"""
|
||||
|
||||
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
|
||||
LAST_LAUNCH_TITLE_STYLE = "font-family: 'Play'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
LAST_LAUNCH_VALUE_STYLE = "font-family: 'Play'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
|
||||
PLAY_TIME_TITLE_STYLE = "font-family: 'Play'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
PLAY_TIME_VALUE_STYLE = "font-family: 'Play'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
|
||||
GAMEPAD_SUPPORT_VALUE_STYLE = """
|
||||
font-family: 'Play'; font-size: 12px; color: #00ff00;
|
||||
font-weight: bold; background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 5px; padding: 4px 8px;
|
||||
"""
|
||||
|
||||
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
|
||||
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
CAPTION_LABEL_STYLE="color: white; font-size: 16px;"
|
||||
|
||||
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
|
||||
def get_protondb_badge_style(tier):
|
||||
tier = tier.lower()
|
||||
tier_colors = {
|
||||
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
|
||||
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
|
||||
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
|
||||
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
|
||||
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
|
||||
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
|
||||
}
|
||||
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
font-size: 16px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Play';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА STEAM
|
||||
STEAM_BADGE_STYLE= """
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Play';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# Favorite Star
|
||||
FAVORITE_LABEL_STYLE = "color: gold; font-size: 32px; background: transparent;"
|
||||
|
||||
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
|
||||
MESSAGE_BOX_STYLE = """
|
||||
QMessageBox {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(40, 40, 40, 0.95),
|
||||
stop:1 rgba(25, 25, 25, 0.95));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
font-family: 'Play';
|
||||
padding: 8px 20px;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background: #09bec8;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
|
||||
# PARAMS_TITLE_STYLE
|
||||
PARAMS_TITLE_STYLE = "color: #ffffff; font-family: 'Play'; font-size: 16px; padding: 10px; background: transparent;"
|
||||
|
||||
PROXY_INPUT_STYLE = """
|
||||
QLineEdit {
|
||||
background: #282a33;
|
||||
border: 0px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: #ffffff;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
QMenu {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 10px;
|
||||
background: #32343d;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 0px 10px;
|
||||
border: 10px solid transparent; /* reserve space for selection border */
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background: #3f424d;
|
||||
border-radius: 10px;
|
||||
}
|
||||
"""
|
||||
|
||||
SETTINGS_COMBO_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: #3f424d;
|
||||
border: 0px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: #ffffff;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: #373a43;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}}
|
||||
/* Состояние фокуса */
|
||||
QComboBox:focus {{
|
||||
border: 2px solid #409EFF;
|
||||
background-color: #404554;
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
/* Список при открытом комбобоксе */
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-style: none;
|
||||
}}
|
||||
QListView {{
|
||||
background: #3f424d;
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
border-radius: 0px;
|
||||
color: #ffffff;
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: #282a33;
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: #282a33;
|
||||
}}
|
||||
/* Выделение в списке при фокусе на элементе */
|
||||
QListView::item:focus {{
|
||||
background: #409EFF;
|
||||
color: #ffffff;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# ФУНКЦИЯ ДЛЯ ДИНАМИЧЕСКОГО ГРАДИЕНТА (ДЕТАЛИ ИГР)
|
||||
# Функции из этой темы срабатывает всегда вне зависимости от выбранной темы, функции из других тем работают только в этих темах
|
||||
def detail_page_style(stops):
|
||||
return f"""
|
||||
QWidget {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
{stops});
|
||||
border-radius: 15px;
|
||||
}}
|
||||
"""
|
159
portprotonqt/time_utils.py
Normal file
@ -0,0 +1,159 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from babel.dates import format_timedelta, format_date
|
||||
from portprotonqt.config_utils import read_time_config
|
||||
from portprotonqt.localization import _, get_system_locale
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def get_cache_file_path():
|
||||
"""Возвращает путь к файлу кеша portproton_last_launch."""
|
||||
cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
return os.path.join(cache_home, "PortProtonQT", "last_launch")
|
||||
|
||||
def save_last_launch(exe_name, launch_time):
|
||||
"""
|
||||
Сохраняет время запуска для exe.
|
||||
Формат файла: <exe_name> <isoformatted_time>
|
||||
"""
|
||||
file_path = get_cache_file_path()
|
||||
data = {}
|
||||
if os.path.exists(file_path):
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
data[parts[0]] = parts[1]
|
||||
data[exe_name] = launch_time.isoformat()
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
for key, iso_time in data.items():
|
||||
f.write(f"{key} {iso_time}\n")
|
||||
|
||||
def format_last_launch(launch_time):
|
||||
"""
|
||||
Форматирует время запуска с использованием Babel.
|
||||
|
||||
Для detail_level "detailed" возвращает относительный формат с добавлением "назад"
|
||||
(например, "2 мин. назад"). Если время меньше минуты – возвращает переведённую строку.
|
||||
Для "brief" – дату в формате "день месяц год" (например, "1 апреля 2023")
|
||||
на основе системной локали.
|
||||
"""
|
||||
detail_level = read_time_config() or "detailed"
|
||||
system_locale = get_system_locale()
|
||||
if detail_level == "detailed":
|
||||
# Вычисляем delta как launch_time - datetime.now() чтобы получить отрицательное значение для прошедшего времени.
|
||||
delta = launch_time - datetime.now()
|
||||
if abs(delta.total_seconds()) < 60:
|
||||
return _("just now")
|
||||
return format_timedelta(delta, locale=system_locale, granularity='second', format='short', add_direction=True)
|
||||
else:
|
||||
return format_date(launch_time, format="d MMMM yyyy", locale=system_locale)
|
||||
|
||||
def get_last_launch(exe_name):
|
||||
"""
|
||||
Читает время последнего запуска для заданного exe из файла кеша.
|
||||
Возвращает время запуска в нужном формате или перевод строки "Never".
|
||||
"""
|
||||
file_path = get_cache_file_path()
|
||||
if not os.path.exists(file_path):
|
||||
return _("Never")
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(maxsplit=1)
|
||||
if len(parts) == 2 and parts[0] == exe_name:
|
||||
iso_time = parts[1]
|
||||
launch_time = datetime.fromisoformat(iso_time)
|
||||
return format_last_launch(launch_time)
|
||||
return _("Never")
|
||||
|
||||
def parse_playtime_file(file_path):
|
||||
"""
|
||||
Парсит файл с данными о времени игры.
|
||||
|
||||
Формат строки в файле:
|
||||
<полный путь к exe> <хэш> <playtime_seconds> <platform> <build>
|
||||
|
||||
Возвращает словарь вида:
|
||||
{
|
||||
'<exe_path>': playtime_seconds (int),
|
||||
...
|
||||
}
|
||||
"""
|
||||
playtime_data = {}
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"Файл не найден: {file_path}")
|
||||
return playtime_data
|
||||
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
exe_path = parts[0]
|
||||
seconds = int(parts[2])
|
||||
playtime_data[exe_path] = seconds
|
||||
return playtime_data
|
||||
|
||||
def format_playtime(seconds):
|
||||
"""
|
||||
Конвертирует время в секундах в форматированную строку с использованием Babel.
|
||||
|
||||
При "detailed" выводится полный разбор времени, без округления
|
||||
(например, "1 ч 1 мин 15 сек").
|
||||
|
||||
При "brief":
|
||||
- если время менее часа, выводится точное время с секундами (например, "9 мин 28 сек"),
|
||||
- если больше часа – только часы (например, "3 ч").
|
||||
"""
|
||||
detail_level = read_time_config() or "detailed"
|
||||
system_locale = get_system_locale()
|
||||
seconds = int(seconds)
|
||||
|
||||
if detail_level == "detailed":
|
||||
days, rem = divmod(seconds, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f"{days} " + _("d."))
|
||||
if hours > 0:
|
||||
parts.append(f"{hours} " + _("h."))
|
||||
if minutes > 0:
|
||||
parts.append(f"{minutes} " + _("min."))
|
||||
if secs > 0 or not parts:
|
||||
parts.append(f"{secs} " + _("sec."))
|
||||
return " ".join(parts)
|
||||
else:
|
||||
# Режим brief
|
||||
if seconds < 3600:
|
||||
minutes, secs = divmod(seconds, 60)
|
||||
parts = []
|
||||
if minutes > 0:
|
||||
parts.append(f"{minutes} " + _("min."))
|
||||
if secs > 0 or not parts:
|
||||
parts.append(f"{secs} " + _("sec."))
|
||||
return " ".join(parts)
|
||||
else:
|
||||
hours = seconds // 3600
|
||||
return format_timedelta(timedelta(hours=hours), locale=system_locale, granularity='hour', format='short')
|
||||
|
||||
def get_last_launch_timestamp(exe_name):
|
||||
"""
|
||||
Возвращает метку времени последнего запуска (timestamp) для заданного exe.
|
||||
Если записи нет, возвращает 0.
|
||||
"""
|
||||
file_path = get_cache_file_path()
|
||||
if not os.path.exists(file_path):
|
||||
return 0
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.strip().split(maxsplit=1)
|
||||
if len(parts) == 2 and parts[0] == exe_name:
|
||||
iso_time = parts[1]
|
||||
dt = datetime.fromisoformat(iso_time)
|
||||
return dt.timestamp()
|
||||
return 0
|
35
portprotonqt/tray.py
Normal file
@ -0,0 +1,35 @@
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from typing import cast
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
|
||||
class SystemTray:
|
||||
def __init__(self, app, theme=None):
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else default_styles
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
self.tray = QSystemTrayIcon()
|
||||
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
|
||||
self.tray.setToolTip("PortProton QT")
|
||||
self.tray.setVisible(True)
|
||||
|
||||
# Создаём меню
|
||||
self.menu = QMenu()
|
||||
|
||||
self.hide_action = QAction("Скрыть окно")
|
||||
self.menu.addAction(self.hide_action)
|
||||
|
||||
self.show_action = QAction("Показать окно")
|
||||
self.menu.addAction(self.show_action)
|
||||
|
||||
self.quit_action = QAction("Выход")
|
||||
self.quit_action.triggered.connect(app.quit)
|
||||
self.menu.addAction(self.quit_action)
|
||||
|
||||
self.tray.setContextMenu(self.menu)
|
||||
|
||||
def hide_tray(self):
|
||||
"""Скрыть иконку трея"""
|
||||
self.tray.hide()
|