17 Commits

Author SHA1 Message Date
8e34c92385 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m33s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:36:12 +05:00
d50b63bca7 fix(steam_api): re-download json lists if it is broken
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:33:00 +05:00
6966253e9b fix(add_game_dialog): check exe path before add game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:14:02 +05:00
13f3af7a42 fix(hltb): return None if all time zero
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-03 20:03:15 +05:00
c7bed80570 chore(changelog): update
All checks were successful
renovate / renovate (push) Successful in 31s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 14:04:48 +05:00
6fde7c18db chore(documentation): fix anchors
All checks were successful
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 11:57:01 +05:00
37782d4375 chore(documentation): mention animation
All checks were successful
Code check / Check code (push) Successful in 1m32s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 11:51:28 +05:00
0a8a7c538c added more animation to detail_page
All checks were successful
Code check / Check code (push) Successful in 1m37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-02 11:35:23 +05:00
Gitea Actions
9cc4b8c51d chore: update steam apps list 2025-08-01T13:12:19Z 2025-08-01 13:12:19 +00:00
397dede2be feat: use devicePixelRatio for image scale
Some checks failed
Code check / Check code (push) Successful in 1m29s
Fetch Data / build (push) Failing after 49s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-29 12:15:22 +05:00
6a66f37ba1 fix: fix open context menu on gamepad
All checks were successful
Code check / Check code (push) Successful in 1m24s
renovate / renovate (push) Successful in 1m3s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 12:22:24 +05:00
4db1cce32c chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:44:43 +05:00
edaeca4f11 feat: set focus on first item of context menu
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:39:40 +05:00
11d44f091d fix(egs): prevent legendary list call when user.json is missing
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:32:14 +05:00
09d9c6510a chore: reduced duration of card opening animation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-25 11:13:54 +05:00
272be51bb0 feat(dev-script): added appimage cleaner script
All checks were successful
Build Check - AppImage, Arch, Fedora / Build AppImage (pull_request) Successful in 2m22s
Build Check - AppImage, Arch, Fedora / Build Fedora RPM (41) (pull_request) Has been skipped
Build Check - AppImage, Arch, Fedora / Build Fedora RPM (rawhide) (pull_request) Has been skipped
Build Check - AppImage, Arch, Fedora / Build Arch Package (pull_request) Has been skipped
Code check / Check code (push) Successful in 1m28s
Build Check - AppImage, Arch, Fedora / changes (pull_request) Successful in 28s
Code check / Check code (pull_request) Successful in 1m32s
Build Check - AppImage, Arch, Fedora / Build Fedora RPM (42) (pull_request) Has been skipped
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-22 14:17:16 +05:00
63933172f9 chore: pulse dropped from autoinstals
All checks were successful
Code check / Check code (push) Successful in 1m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-07-21 19:22:05 +05:00
25 changed files with 8597 additions and 1233 deletions

View File

@@ -17,11 +17,11 @@ jobs:
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
- name: Install tools - name: Install tools
run: | run: |
pip3 install git+https://github.com/Frederic98/appimage-builder.git pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv pip3 install uv
- name: Build AppImage - name: Build AppImage

View File

@@ -23,11 +23,11 @@ jobs:
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
- name: Install tools - name: Install tools
run: | run: |
pip3 install git+https://github.com/Frederic98/appimage-builder.git pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv pip3 install uv
- name: Build AppImage - name: Build AppImage
@@ -159,6 +159,7 @@ jobs:
mkdir -p extracted mkdir -p extracted
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \; find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
find extracted/ -type f -exec mv {} release/ \; find extracted/ -type f -exec mv {} release/ \;
find release/ -name '*.zip' -delete
rm -rf extracted/ rm -rf extracted/
- name: Extract changelog for version - name: Extract changelog for version

View File

@@ -68,11 +68,11 @@ jobs:
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync zstd git
- name: Install tools - name: Install tools
run: | run: |
pip3 install git+https://github.com/Frederic98/appimage-builder.git pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv pip3 install uv
- name: Build AppImage - name: Build AppImage

View File

@@ -3,6 +3,28 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Больше типов анимаций при открытии карточки игры (за подробностями в документацию)
### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350мс
- Контекстное меню при открытие теперь сразу фокусируется на первом элементе
- Анимации теперь можно настраивать через темы (за подробностями в документацию)
- Общие json (steam_apps и anticheat_games) теперь перекачиваются если сломаны
### Fixed
- legendary list теперь не вызывается если вход в EGS не был произведён
- Скриншоты тем теперь не теряют в качестве при масштабе отличном от 100%
- Данные от HLTB теперь не отображаются в карточке если нет данных о времени прохождения
- Диалог добавления игры теперь не добавляет игру если exe не существует
### Contributors
---
## [0.1.4] - 2025-07-21 ## [0.1.4] - 2025-07-21
### Added ### Added

View File

@@ -13,9 +13,9 @@ script:
# 5) чистим от ненужных модулей и бинарников # 5) чистим от ненужных модулей и бинарников
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
- shopt -s extglob - shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*) - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
AppDir: AppDir:
path: ./AppDir path: ./AppDir
after_bundle: after_bundle:
@@ -82,5 +82,4 @@ AppDir:
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34' PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
AppImage: AppImage:
sign-key: None sign-key: None
comp: xz
arch: x86_64 arch: x86_64

View File

@@ -765,7 +765,7 @@
}, },
{ {
"normalized_name": "lost ark", "normalized_name": "lost ark",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "archeage unchained", "normalized_name": "archeage unchained",
@@ -4426,5 +4426,61 @@
{ {
"normalized_name": "carx street", "normalized_name": "carx street",
"status": "Broken" "status": "Broken"
},
{
"normalized_name": "warcos 2",
"status": "Broken"
},
{
"normalized_name": "karos classic",
"status": "Broken"
},
{
"normalized_name": "dead island riptide",
"status": "Running"
},
{
"normalized_name": "lineage",
"status": "Broken"
},
{
"normalized_name": "day of dragons",
"status": "Running"
},
{
"normalized_name": "sonic rumble",
"status": "Broken"
},
{
"normalized_name": "black stigma",
"status": "Broken"
},
{
"normalized_name": "umamusume pretty derby",
"status": "Running"
},
{
"normalized_name": "dirt rally",
"status": "Supported"
},
{
"normalized_name": "minifighter",
"status": "Broken"
},
{
"normalized_name": "hide & hold out h2o",
"status": "Running"
},
{
"normalized_name": "f1 25",
"status": "Denied"
},
{
"normalized_name": "ghost of tsushima director's cut",
"status": "Denied"
},
{
"normalized_name": "sword of justice",
"status": "Broken"
} }
] ]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,12 +1,56 @@
[ [
{ {
"normalized_title": "return alive", "normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "return-alive" "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
},
{
"normalized_title": "dead island 2",
"slug": "dead-island-2"
},
{
"normalized_title": "dead island",
"slug": "dead-island-definitive-edition"
},
{
"normalized_title": "wuchang fallen feathers",
"slug": "wuchang-fallen-feathers"
},
{
"normalized_title": "mindseye",
"slug": "mindseye"
},
{
"normalized_title": "alan wake",
"slug": "alan-wake"
}, },
{ {
"normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a", "normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
"slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a" "slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
}, },
{
"normalized_title": "fifa 18",
"slug": "fifa-18"
},
{
"normalized_title": "eriksholm the stolen dream",
"slug": "eriksholm-the-stolen-dream"
},
{
"normalized_title": "caravan sandwitch",
"slug": "caravan-sandwitch"
},
{
"normalized_title": "expeditions a mudrunner game",
"slug": "expeditions-a-mudrunner-game"
},
{
"normalized_title": "#drive rally",
"slug": "drive-rally"
},
{
"normalized_title": "return alive",
"slug": "return-alive"
},
{ {
"normalized_title": "recore", "normalized_title": "recore",
"slug": "recore-definitive-edition" "slug": "recore-definitive-edition"

Binary file not shown.

378
dev-scripts/appimage_clean.py Executable file
View File

@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""
PySide6 Dependencies Analyzer with ldd support
Анализирует зависимости PySide6 модулей используя ldd для определения
реальных зависимостей скомпилированных библиотек.
"""
import ast
import os
import sys
import subprocess
import re
from pathlib import Path
from typing import Set, Dict, List
import argparse
import json
class PySide6DependencyAnalyzer:
def __init__(self):
# Системные библиотеки, которые нужно всегда оставлять
self.system_libs = {
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
}
self.real_dependencies = {}
self.used_modules_code = set()
self.used_modules_ldd = set()
self.all_required_modules = set()
def find_python_files(self, directory: Path) -> List[Path]:
"""Находит все Python файлы в директории"""
python_files = []
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
for file in files:
if file.endswith('.py'):
python_files.append(Path(root) / file)
return python_files
def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
"""Находит все PySide6 библиотеки (.so файлы)"""
libs = {}
# Поиск в единственной локации
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
print(f"Поиск PySide6 библиотек в: {search_path}")
if search_path.exists():
# Ищем .so файлы модулей
for so_file in search_path.glob("Qt*.*.so"):
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
if module_name.startswith('Qt'):
libs[module_name] = so_file
# Также ищем в подпапках
for subdir in search_path.iterdir():
if subdir.is_dir() and subdir.name.startswith('Qt'):
for so_file in subdir.glob("*.so*"):
if 'Qt' in so_file.name:
libs[subdir.name] = so_file
break
return libs
def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
"""Анализирует зависимости библиотеки с помощью ldd"""
qt_deps = set()
try:
result = subprocess.run(['ldd', str(lib_path)],
capture_output=True, text=True, check=True)
# Парсим вывод ldd и ищем Qt библиотеки
for line in result.stdout.split('\n'):
# Ищем строки вида: libQt6Core.so.6 => /path/to/lib
match = re.search(r'libQt6(\w+)\.so', line)
if match:
qt_module = f"Qt{match.group(1)}"
qt_deps.add(qt_module)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
return qt_deps
def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
"""Строит граф зависимостей на основе ldd анализа"""
dependencies = {}
print("Анализ реальных зависимостей с помощью ldd...")
for module, lib_path in pyside_libs.items():
print(f" Анализируется {module}...")
deps = self.analyze_ldd_dependencies(lib_path)
dependencies[module] = deps
if deps:
print(f" Зависимости: {', '.join(sorted(deps))}")
return dependencies
def analyze_file_imports(self, file_path: Path) -> Set[str]:
"""Анализирует один Python файл и возвращает используемые PySide6 модули"""
modules = set()
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith('PySide6.'):
module = alias.name.split('.', 2)[1]
if module.startswith('Qt'):
modules.add(module)
elif isinstance(node, ast.ImportFrom):
if node.module and node.module.startswith('PySide6.'):
module = node.module.split('.', 2)[1]
if module.startswith('Qt'):
modules.add(module)
except Exception as e:
print(f"Ошибка при анализе {file_path}: {e}")
return modules
def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
"""Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
all_deps = set(modules)
if not dependency_graph:
return all_deps
# Повторяем до тех пор, пока не найдем все транзитивные зависимости
changed = True
iteration = 0
while changed and iteration < 10: # Защита от бесконечного цикла
changed = False
current_deps = set(all_deps)
for module in current_deps:
if module in dependency_graph:
new_deps = dependency_graph[module] - all_deps
if new_deps:
all_deps.update(new_deps)
changed = True
iteration += 1
return all_deps
def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
"""Анализирует весь проект"""
python_files = self.find_python_files(project_path)
print(f"Найдено {len(python_files)} Python файлов")
# Анализ статических импортов
used_modules_code = set()
file_modules = {}
for file_path in python_files:
modules = self.analyze_file_imports(file_path)
if modules:
file_modules[str(file_path.relative_to(project_path))] = list(modules)
used_modules_code.update(modules)
print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
# Поиск PySide6 библиотек
search_base = appdir_path if appdir_path else project_path
pyside_libs = self.find_pyside6_libs(search_base)
if not pyside_libs:
print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
return {
'error': 'PySide6 библиотеки не найдены',
'analysis_method': 'failed',
'found_libraries': 0,
'directly_used_code': sorted(used_modules_code),
'all_required': [],
'removable': [],
'available_modules': [],
'file_usage': file_modules
}
print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
# Анализ реальных зависимостей с ldd
real_dependencies = self.build_real_dependency_graph(pyside_libs)
# Определяем модули, которые реально используются через ldd
used_modules_ldd = set()
for module in used_modules_code:
if module in real_dependencies:
used_modules_ldd.update(real_dependencies[module])
used_modules_ldd.add(module)
print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
# Объединяем результаты анализа кода и ldd
all_used_modules = used_modules_code | used_modules_ldd
# Получаем все необходимые модули включая зависимости
all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
# Все доступные PySide6 модули
available_modules = set(pyside_libs.keys())
# Модули, которые можно удалить
removable = available_modules - all_required
return {
'analysis_method': 'ldd + static analysis',
'found_libraries': len(pyside_libs),
'directly_used_code': sorted(used_modules_code),
'directly_used_ldd': sorted(used_modules_ldd),
'all_required': sorted(all_required),
'removable': sorted(removable),
'available_modules': sorted(available_modules),
'file_usage': file_modules,
'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
'library_paths': {k: str(v) for k, v in pyside_libs.items()},
'analysis_summary': {
'total_modules': len(available_modules),
'required_modules': len(all_required),
'removable_modules': len(removable),
'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
}
}
def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
"""Генерирует обновленный AppImage рецепт с командами очистки"""
# Читаем существующий рецепт
try:
with open(template_path, 'r', encoding='utf-8') as f:
recipe_content = f.read()
except FileNotFoundError:
print(f"Шаблон рецепта не найден: {template_path}")
return ""
# Генерируем новые команды очистки
cleanup_lines = []
# QML удаляем только если не используется
qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
if qml_modules.issubset(set(removable_modules)):
cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
# Инструменты разработки (всегда удаляем)
cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
# Модули для удаления
if removable_modules:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
# Генерируем команду для удаления нативных библиотек с сохранением нужных
required_libs = set()
for module in sorted(set(self.all_required_modules)):
required_libs.add(f"libQt6{module.replace('Qt', '')}*")
# Добавляем системные библиотеки
for lib in self.system_libs:
required_libs.add(f"{lib}*")
keep_pattern = '|'.join(sorted(required_libs))
cleanup_lines.extend([
" - shopt -s extglob",
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
])
# Заменяем блок очистки в рецепте
import re
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)'
new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
return updated_recipe
def main():
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
parser.add_argument('project_path', help='Путь к проекту для анализа')
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
args = parser.parse_args()
project_path = Path(args.project_path)
if not project_path.exists():
print(f"Ошибка: путь {project_path} не существует")
sys.exit(1)
appdir_path = Path(args.appdir) if args.appdir else None
if appdir_path and not appdir_path.exists():
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
appdir_path = None
analyzer = PySide6DependencyAnalyzer()
results = analyzer.analyze_project(project_path, appdir_path)
# Сохраняем в анализатор для генерации команд
analyzer.all_required_modules = set(results.get('all_required', []))
# Выводим результаты
print("\n" + "="*60)
print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
print("="*60)
if 'error' in results:
print(f"\nОШИБКА: {results['error']}")
sys.exit(1)
print(f"\nМетод анализа: {results['analysis_method']}")
print(f"Найдено библиотек: {results['found_libraries']}")
if results['directly_used_code']:
print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
for module in results['directly_used_code']:
print(f"{module}")
if results['directly_used_ldd']:
print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
for module in results['directly_used_ldd']:
print(f"{module}")
print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
for module in results['all_required']:
print(f"{module}")
print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
for module in results['removable']:
print(f"{module}")
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
if args.verbose and results['real_dependencies']:
Devlin(f"\nРеальные зависимости (ldd):")
for module, deps in results['real_dependencies'].items():
if deps:
print(f" {module}{', '.join(deps)}")
# Обновляем AppImage рецепт
recipe_path = Path("../build-aux/AppImageBuilder.yml")
if recipe_path.exists():
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
if updated_recipe:
with open(recipe_path, 'w', encoding='utf-8') as f:
f.write(updated_recipe)
print(f"\nAppImage рецепт обновлен: {recipe_path}")
else:
print(f"\nОШИБКА: не удалось обновить рецепт")
else:
print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
# Сохраняем результаты в JSON
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"Результаты сохранены в: {args.output}")
print("\n" + "="*60)
if __name__ == "__main__":
main()

View File

@@ -3,12 +3,13 @@
--- ---
## 📋 Contents ## 📋 Contents
- [Overview](#overview) - [Overview](#-overview)
- [Creating the Theme Folder](#creating-the-theme-folder) - [Creating the Theme Folder](#-creating-the-theme-folder)
- [Style File](#style-file) - [Style File](#-style-file-stylespy)
- [Metadata](#metadata) - [Animation configuration](#-animation-configuration)
- [Screenshots](#screenshots) - [Metadata](#-metadata-metainfoini)
- [Fonts and Icons](#fonts-and-icons) - [Screenshots](#-screenshots)
- [Fonts and Icons](#-fonts-and-icons-optional)
--- ---
@@ -45,6 +46,53 @@ def custom_button_style(color1, color2):
--- ---
## 🎥 Animation configuration
The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game cards:
```python
GAME_CARD_ANIMATION = {
# Animation type when transitioning to a detailed page
# Available values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce", "none"
"detail_page_animation_type": "fade",
# Border width settings (in pixels)
"default_border_width": 2,
"hover_border_width": 8,
"focus_border_width": 12,
"pulse_min_border_width": 8,
"pulse_max_border_width": 10,
# Animation duration (in milliseconds)
"thickness_anim_duration": 300,
"pulse_anim_duration": 800,
"gradient_anim_duration": 3000,
# Gradient animation angles (in degrees)
"gradient_start_angle": 360,
"gradient_end_angle": 0,
# Smoothing curves for smooth animations
"thickness_easing_curve": "OutBack",
"thickness_easing_curve_out": "InBack",
# Gradient colors for animated stroke
"gradient_colors": [
{"position": 0, "color": "#00fff5"},
{"position": 0.33, "color": "#FF5733"},
{"position": 0.66, "color": "#9B59B6"},
{"position": 1, "color": "#00fff5"}
],
# Duration of transitions to the detailed page
"detail_page_fade_duration": 350,
"detail_page_slide_duration": 500,
"detail_page_zoom_duration": 400
}
```
---
## 📝 Metadata (`metainfo.ini`) ## 📝 Metadata (`metainfo.ini`)
```ini ```ini

View File

@@ -3,12 +3,13 @@
--- ---
## 📋 Содержание ## 📋 Содержание
- [Обзор](#обзор) - [Обзор](#-обзор)
- [Создание папки темы](#создание-папки-темы) - [Создание папки темы](#-создание-папки-темы)
- [Файл стилей](#файл-стилей) - [Файл стилей](#-файл-стилей-stylespy)
- [Метаинформация](#метаинформация) - [Конфигурация анимации](#-конфигурация-анимации)
- [Скриншоты](#скриншоты) - [Метаинформация](#-метаинформация-metainfoini)
- [Шрифты и иконки](#шрифты-и-иконки) - [Скриншоты](#-скриншоты)
- [Шрифты и иконки](#-шрифты-и-иконки-опционально)
--- ---
@@ -45,6 +46,53 @@ def custom_button_style(color1, color2):
--- ---
## 🎥 Конфигурация анимации
Словарь `GAME_CARD_ANIMATION` управляет всеми параметрами анимации для карточек игр:
```python
GAME_CARD_ANIMATION = {
# Тип анимации при переходе на детальную страницу
# Доступные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce", "none"
"detail_page_animation_type": "fade",
# Настройки ширины обводки (в пикселях)
"default_border_width": 2,
"hover_border_width": 8,
"focus_border_width": 12,
"pulse_min_border_width": 8,
"pulse_max_border_width": 10,
# Длительности анимаций (в миллисекундах)
"thickness_anim_duration": 300,
"pulse_anim_duration": 800,
"gradient_anim_duration": 3000,
# Углы анимации градиента (в градусах)
"gradient_start_angle": 360,
"gradient_end_angle": 0,
# Кривые сглаживания для плавных анимаций
"thickness_easing_curve": "OutBack",
"thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
"gradient_colors": [
{"position": 0, "color": "#00fff5"},
{"position": 0.33, "color": "#FF5733"},
{"position": 0.66, "color": "#9B59B6"},
{"position": 1, "color": "#00fff5"}
],
# Длительности переходов на детальную страницу
"detail_page_fade_duration": 350,
"detail_page_slide_duration": 500,
"detail_page_zoom_duration": 400
}
```
---
## 📝 Метаинформация (`metainfo.ini`) ## 📝 Метаинформация (`metainfo.ini`)
```ini ```ini

View File

@@ -280,7 +280,12 @@ class ContextMenuManager:
) )
) )
menu.exec(game_card.mapToGlobal(pos)) # Устанавливаем фокус на первый элемент меню
actions = menu.actions()
if actions:
menu.setActiveAction(actions[0])
menu.exec(game_card.mapToGlobal(pos))
def _launch_game(self, game_card): def _launch_game(self, game_card):
""" """

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

View File

@@ -1,3 +0,0 @@
name=Pulse Online
description_ru=Многопользовательская онлайн-игра в жанре MMORPG, действие которой происходит в научно-фантастическом мире с уникальной боевой системой и глубоким крафтом. Игроки могут исследовать обширные локации, выполнять квесты, сражаться с противниками и взаимодействовать с другими участниками игры.
description_en=A multiplayer online game in the MMORPG genre set in a sci-fi world with a unique combat system and deep crafting mechanics. Players can explore vast locations, complete quests, battle enemies, and interact with other participants in the game.

View File

@@ -677,7 +677,10 @@ class AddGameDialog(QDialog):
exe_path = self.exeEdit.text().strip() exe_path = self.exeEdit.text().strip()
name = self.nameEdit.text().strip() name = self.nameEdit.text().strip()
if not exe_path or not name: if not exe_path or not os.path.isfile(exe_path):
return None, None
if not name:
return None, None return None, None
portproton_path = get_portproton_location() portproton_path = get_portproton_location()

View File

@@ -747,6 +747,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
games: list[tuple] = [] games: list[tuple] = []
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
user_json_path = cache_dir / "user.json"
if not user_json_path.exists():
callback(games)
return
def process_games(installed_games: list | None): def process_games(installed_games: list | None):
if installed_games is None: if installed_games is None:
logger.info("No installed Epic Games Store games found") logger.info("No installed Epic Games Store games found")
@@ -855,12 +860,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
app_name, app_name,
f"legendary:launch:{app_name}", f"legendary:launch:{app_name}",
"", "",
last_launch, # Время последнего запуска last_launch,
formatted_playtime, # Форматированное время игры formatted_playtime,
protondb_tier, # ProtonDB tier protondb_tier,
status or "", status or "",
last_launch_timestamp, # Временная метка последнего запуска last_launch_timestamp,
playtime_seconds, # Время игры в секундах playtime_seconds,
"epic" "epic"
) )
pending_images -= 1 pending_images -= 1
@@ -880,7 +885,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
get_protondb_tier_async(steam_appid, on_protondb_tier) get_protondb_tier_async(steam_appid, on_protondb_tier)
else: else:
logger.debug(f"No Steam app found for EGS game {title}") logger.debug(f"No Steam app found for EGS game {title}")
on_protondb_tier("") # Proceed with empty ProtonDB tier on_protondb_tier("")
get_steam_apps_and_index_async(on_steam_apps) get_steam_apps_and_index_async(on_steam_apps)

View File

@@ -219,9 +219,11 @@ class ResultParser:
("comp_plus", "main_extra"), ("comp_plus", "main_extra"),
("comp_100", "completionist") ("comp_100", "completionist")
] ]
all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields)
for json_field, attr_name in time_fields: for json_field, attr_name in time_fields:
if json_field in game_data: if json_field in game_data:
time_hours = round(game_data[json_field] / 3600, 2) time_seconds = game_data[json_field]
time_hours = None if all_zero else round(time_seconds / 3600, 2)
setattr(game, attr_name, time_hours) setattr(game, attr_name, time_hours)
game.similarity = self._calculate_similarity(game) game.similarity = self._calculate_similarity(game)
return game return game

View File

@@ -21,6 +21,13 @@ image_load_queue = Queue()
image_executor = ThreadPoolExecutor(max_workers=4) image_executor = ThreadPoolExecutor(max_workers=4)
queue_lock = threading.Lock() queue_lock = threading.Lock()
def get_device_pixel_ratio() -> float:
"""
Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available.
"""
app = QApplication.instance()
return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""): def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
""" """
Асинхронно загружает обложку через очередь задач. Асинхронно загружает обложку через очередь задач.
@@ -164,7 +171,6 @@ class FullscreenDialog(QDialog):
:param theme: Объект темы для стилизации (если None, используется default_styles) :param theme: Объект темы для стилизации (если None, используется default_styles)
""" """
super().__init__(parent) super().__init__(parent)
# Удаление диалога после закрытия
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus() self.setFocus()
@@ -173,14 +179,12 @@ class FullscreenDialog(QDialog):
self.current_index = current_index self.current_index = current_index
self.theme = theme if theme else default_styles self.theme = theme if theme else default_styles
# Убираем стандартные элементы управления окна
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.init_ui() self.init_ui()
self.update_display() self.update_display()
# Фильтруем события для закрытия диалога по клику
self.imageLabel.installEventFilter(self) self.imageLabel.installEventFilter(self)
self.captionLabel.installEventFilter(self) self.captionLabel.installEventFilter(self)
@@ -190,32 +194,28 @@ class FullscreenDialog(QDialog):
self.mainLayout.setContentsMargins(0, 0, 0, 0) self.mainLayout.setContentsMargins(0, 0, 0, 0)
self.mainLayout.setSpacing(0) self.mainLayout.setSpacing(0)
# Контейнер для изображения и стрелок
self.imageContainer = QWidget() self.imageContainer = QWidget()
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT) self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
self.imageContainerLayout = QHBoxLayout(self.imageContainer) self.imageContainerLayout = QHBoxLayout(self.imageContainer)
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0) self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
self.imageContainerLayout.setSpacing(0) self.imageContainerLayout.setSpacing(0)
# Левая стрелка
self.prevButton = QToolButton() self.prevButton = QToolButton()
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow) self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE) self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor) self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
self.prevButton.setFixedSize(40, 40) self.prevButton.setFixedSize(40, 40)
self.prevButton.clicked.connect(self.show_prev) self.prevButton.clicked.connect(self.show_prev)
self.imageContainerLayout.addWidget(self.prevButton) self.imageContainerLayout.addWidget(self.prevButton)
# Метка для изображения
self.imageLabel = QLabel() self.imageLabel = QLabel()
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT) self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1) self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
# Правая стрелка
self.nextButton = QToolButton() self.nextButton = QToolButton()
self.nextButton.setArrowType(Qt.ArrowType.RightArrow) self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor) self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
self.nextButton.setFixedSize(40, 40) self.nextButton.setFixedSize(40, 40)
self.nextButton.clicked.connect(self.show_next) self.nextButton.clicked.connect(self.show_next)
@@ -223,16 +223,14 @@ class FullscreenDialog(QDialog):
self.mainLayout.addWidget(self.imageContainer) self.mainLayout.addWidget(self.imageContainer)
# Небольшой отступ между изображением и подписью
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self.mainLayout.addItem(spacer) self.mainLayout.addItem(spacer)
# Подпись
self.captionLabel = QLabel() self.captionLabel = QLabel()
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.captionLabel.setFixedHeight(40) self.captionLabel.setFixedHeight(40)
self.captionLabel.setWordWrap(True) self.captionLabel.setWordWrap(True)
self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE) self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", ""))
self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor) self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
self.mainLayout.addWidget(self.captionLabel) self.mainLayout.addWidget(self.captionLabel)
@@ -241,28 +239,37 @@ class FullscreenDialog(QDialog):
if not self.images: if not self.images:
return return
# Очищаем старое содержимое
self.imageLabel.clear() self.imageLabel.clear()
self.captionLabel.clear() self.captionLabel.clear()
QApplication.processEvents() QApplication.processEvents()
pixmap, caption = self.images[self.current_index] pixmap, caption = self.images[self.current_index]
# Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера # Учитываем devicePixelRatio для масштабирования высокого качества
device_pixel_ratio = get_device_pixel_ratio()
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
# Масштабируем изображение из оригинального pixmap
scaled_pixmap = pixmap.scaled( scaled_pixmap = pixmap.scaled(
self.FIXED_WIDTH - 80, # учитываем ширину стрелок target_width,
self.FIXED_HEIGHT, target_height,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation Qt.TransformationMode.SmoothTransformation
) )
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.imageLabel.setPixmap(scaled_pixmap) self.imageLabel.setPixmap(scaled_pixmap)
self.captionLabel.setText(caption) self.captionLabel.setText(caption)
self.setWindowTitle(caption) self.setWindowTitle(caption)
# Принудительная перерисовка виджетов
self.imageLabel.repaint() self.imageLabel.repaint()
self.captionLabel.repaint() self.captionLabel.repaint()
self.repaint() self.repaint()
def resizeEvent(self, event):
"""Обновляет изображение при изменении размера окна."""
super().resizeEvent(event)
self.update_display() # Перерисовываем изображение с учетом нового размера
def show_prev(self): def show_prev(self):
"""Показывает предыдущее изображение.""" """Показывает предыдущее изображение."""
if self.images: if self.images:
@@ -292,7 +299,6 @@ class FullscreenDialog(QDialog):
def mousePressEvent(self, event): def mousePressEvent(self, event):
"""Закрывает диалог при клике на пустую область.""" """Закрывает диалог при клике на пустую область."""
pos = event.pos() pos = event.pos()
# Проверяем, находится ли клик вне imageContainer и captionLabel
if not (self.imageContainer.geometry().contains(pos) or if not (self.imageContainer.geometry().contains(pos) or
self.captionLabel.geometry().contains(pos)): self.captionLabel.geometry().contains(pos)):
self.close() self.close()
@@ -305,15 +311,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
""" """
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None): def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
""" """
:param pixmap: QPixmap для отображения в карусели :param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
:param caption: Подпись к изображению :param caption: Подпись к изображению
:param images_list: Список всех изображений (кортежей (QPixmap, caption)), :param images_list: Список всех изображений (кортежей (QPixmap, caption))
чтобы в диалоге можно было перелистывать. :param index: Индекс текущего изображения в images_list
Если не передан, будет использован только текущее изображение. :param carousel: Ссылка на родительскую карусель (ImageCarousel)
:param index: Индекс текущего изображения в images_list.
:param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
""" """
super().__init__(pixmap) super().__init__()
self.original_pixmap = pixmap # Store original high-resolution pixmap
self.caption = caption self.caption = caption
self.images_list = images_list if images_list is not None else [(pixmap, caption)] self.images_list = images_list if images_list is not None else [(pixmap, caption)]
self.index = index self.index = index
@@ -323,6 +328,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
self._click_start_position = None self._click_start_position = None
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
self.update_pixmap() # Set initial pixmap
def update_pixmap(self, height=300):
"""Update the displayed pixmap by scaling from the original high-resolution pixmap."""
if self.original_pixmap.isNull():
return
# Scale pixmap to desired height, considering device pixel ratio
device_pixel_ratio = get_device_pixel_ratio()
scaled_pixmap = self.original_pixmap.scaledToHeight(
int(height * device_pixel_ratio),
Qt.TransformationMode.SmoothTransformation
)
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.setPixmap(scaled_pixmap)
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
@@ -339,17 +358,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
event.accept() event.accept()
def show_fullscreen(self): def show_fullscreen(self):
# Скрываем стрелки карусели перед открытием FullscreenDialog
if self.carousel: if self.carousel:
self.carousel.prevArrow.hide() self.carousel.prevArrow.hide()
self.carousel.nextArrow.hide() self.carousel.nextArrow.hide()
dialog = FullscreenDialog(self.images_list, current_index=self.index) dialog = FullscreenDialog(self.images_list, current_index=self.index)
dialog.exec() dialog.exec()
# После закрытия диалога обновляем видимость стрелок
if self.carousel: if self.carousel:
self.carousel.update_arrows_visibility() self.carousel.update_arrows_visibility()
class ImageCarousel(QGraphicsView): class ImageCarousel(QGraphicsView):
""" """
Карусель изображений с адаптивностью, возможностью увеличения по клику Карусель изображений с адаптивностью, возможностью увеличения по клику
@@ -357,19 +373,16 @@ class ImageCarousel(QGraphicsView):
""" """
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None): def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
super().__init__(parent) super().__init__(parent)
# Аннотируем тип scene как QGraphicsScene
self.carousel_scene: QGraphicsScene = QGraphicsScene(self) self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
self.setScene(self.carousel_scene) self.setScene(self.carousel_scene)
self.images = images # Список кортежей: (QPixmap, caption) self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = [] self.image_items = []
self._animation = None self._animation = None
self.theme = theme if theme else default_styles self.theme = theme if theme else default_styles
self.max_height = 300 # Default height for images
self.init_ui() self.init_ui()
self.create_arrows() self.create_arrows()
# Переменные для поддержки перетаскивания
self._drag_active = False self._drag_active = False
self._drag_start_position = None self._drag_start_position = None
self._scroll_start_value = None self._scroll_start_value = None
@@ -380,30 +393,38 @@ class ImageCarousel(QGraphicsView):
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setFrameShape(QFrame.Shape.NoFrame) self.setFrameShape(QFrame.Shape.NoFrame)
x_offset = 10 # Отступ между изображениями self.update_scene()
max_height = 300 # Фиксированная высота изображений
def update_scene(self):
"""Update the scene with scaled images based on current size and scale."""
self.carousel_scene.clear()
self.image_items.clear()
x_offset = 10
x = 0 x = 0
device_pixel_ratio = get_device_pixel_ratio()
for i, (pixmap, caption) in enumerate(self.images): for i, (pixmap, caption) in enumerate(self.images):
item = ClickablePixmapItem( item = ClickablePixmapItem(
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation), pixmap, # Pass original pixmap
caption, caption,
images_list=self.images, images_list=self.images,
index=i, index=i,
carousel=self # Передаем ссылку на карусель carousel=self
) )
item.update_pixmap(self.max_height) # Scale to current height
item.setPos(x, 0) item.setPos(x, 0)
self.carousel_scene.addItem(item) self.carousel_scene.addItem(item)
self.image_items.append(item) self.image_items.append(item)
x += item.pixmap().width() + x_offset x += item.pixmap().width() / device_pixel_ratio + x_offset
self.setSceneRect(0, 0, x, max_height) self.setSceneRect(0, 0, x, self.max_height)
def create_arrows(self): def create_arrows(self):
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки.""" """Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
self.prevArrow = QToolButton(self) self.prevArrow = QToolButton(self)
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow) self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
self.prevArrow.setFixedSize(40, 40) self.prevArrow.setFixedSize(40, 40)
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor) self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
self.prevArrow.setAutoRepeat(True) self.prevArrow.setAutoRepeat(True)
@@ -414,7 +435,7 @@ class ImageCarousel(QGraphicsView):
self.nextArrow = QToolButton(self) self.nextArrow = QToolButton(self)
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow) self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
self.nextArrow.setFixedSize(40, 40) self.nextArrow.setFixedSize(40, 40)
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor) self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
self.nextArrow.setAutoRepeat(True) self.nextArrow.setAutoRepeat(True)
@@ -423,14 +444,9 @@ class ImageCarousel(QGraphicsView):
self.nextArrow.clicked.connect(self.scroll_right) self.nextArrow.clicked.connect(self.scroll_right)
self.nextArrow.raise_() self.nextArrow.raise_()
# Проверяем видимость стрелок при создании
self.update_arrows_visibility() self.update_arrows_visibility()
def update_arrows_visibility(self): def update_arrows_visibility(self):
"""
Показывает стрелки, если контент шире видимой области.
Иначе скрывает их.
"""
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"): if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
if self.horizontalScrollBar().maximum() == 0: if self.horizontalScrollBar().maximum() == 0:
self.prevArrow.hide() self.prevArrow.hide()
@@ -444,7 +460,8 @@ class ImageCarousel(QGraphicsView):
margin = 10 margin = 10
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2) self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
self.nextArrow.move(self.width() - self.nextArrow.width() - margin, self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
(self.height() - self.nextArrow.height()) // 2) (self.height() - self.nextArrow.height()) // 2)
self.update_scene() # Re-scale images on resize
self.update_arrows_visibility() self.update_arrows_visibility()
def animate_scroll(self, end_value): def animate_scroll(self, end_value):
@@ -469,19 +486,15 @@ class ImageCarousel(QGraphicsView):
self.animate_scroll(new_value) self.animate_scroll(new_value)
def update_images(self, new_images): def update_images(self, new_images):
self.carousel_scene.clear()
self.images = new_images self.images = new_images
self.image_items.clear() self.update_scene()
self.init_ui()
self.update_arrows_visibility() self.update_arrows_visibility()
# Обработка событий мыши для перетаскивания
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
self._drag_active = True self._drag_active = True
self._drag_start_position = event.pos() self._drag_start_position = event.pos()
self._scroll_start_value = self.horizontalScrollBar().value() self._scroll_start_value = self.horizontalScrollBar().value()
# Скрываем стрелки при начале перетаскивания
if hasattr(self, "prevArrow"): if hasattr(self, "prevArrow"):
self.prevArrow.hide() self.prevArrow.hide()
if hasattr(self, "nextArrow"): if hasattr(self, "nextArrow"):
@@ -497,6 +510,5 @@ class ImageCarousel(QGraphicsView):
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
self._drag_active = False self._drag_active = False
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
self.update_arrows_visibility() self.update_arrows_visibility()
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)

View File

@@ -35,14 +35,13 @@ from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QGraphicsEffect, QGraphicsOpacityEffect, QApplication, QPushButton, QProgressBar, QCheckBox) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QGraphicsOpacityEffect, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot, QEasingCurve, QParallelAnimationGroup, QRect
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot
from typing import cast from typing import cast
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime from datetime import datetime
from PySide6.QtWidgets import QSizePolicy
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -1880,17 +1879,126 @@ class MainWindow(QMainWindow):
self.current_play_button = playButton self.current_play_button = playButton
# Анимация # Анимация
opacityEffect = QGraphicsOpacityEffect(detailPage) animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
detailPage.setGraphicsEffect(opacityEffect) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
animation = QPropertyAnimation(opacityEffect, QByteArray(b"opacity"))
animation.setDuration(800) if animation_type == "fade":
animation.setStartValue(0) opacity_effect = QGraphicsOpacityEffect(detailPage)
animation.setEndValue(1) detailPage.setGraphicsEffect(opacity_effect)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped) animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
self._animations[detailPage] = animation animation.setDuration(duration)
animation.finished.connect( animation.setStartValue(0)
lambda: detailPage.setGraphicsEffect(cast(QGraphicsEffect, None)) animation.setEndValue(1)
) animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = animation
animation.finished.connect(lambda: None)
elif animation_type == "slide_left":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
detailPage.move(self.width(), 0)
animation = QPropertyAnimation(detailPage, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(detailPage.pos())
animation.setEndValue(self.stackedWidget.rect().topLeft())
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = animation
elif animation_type == "slide_right":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
detailPage.move(-self.width(), 0)
animation = QPropertyAnimation(detailPage, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(detailPage.pos())
animation.setEndValue(self.stackedWidget.rect().topLeft())
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = animation
elif animation_type == "slide_up":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
detailPage.move(0, self.height())
animation = QPropertyAnimation(detailPage, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(detailPage.pos())
animation.setEndValue(self.stackedWidget.rect().topLeft())
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = animation
elif animation_type == "slide_down":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
detailPage.move(0, -self.height())
animation = QPropertyAnimation(detailPage, QByteArray(b"pos"))
animation.setDuration(duration)
animation.setStartValue(detailPage.pos())
animation.setEndValue(self.stackedWidget.rect().topLeft())
animation.setEasingCurve(QEasingCurve.Type.OutCubic)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = animation
elif animation_type == "bounce":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_zoom_duration", 400)
detailPage.setWindowOpacity(0.0)
opacity_anim = QPropertyAnimation(detailPage, QByteArray(b"windowOpacity"))
opacity_anim.setDuration(duration)
opacity_anim.setStartValue(0.0)
opacity_anim.setEndValue(1.0)
# Animate geometry
initial_rect = QRect(detailPage.x() + detailPage.width() // 4, detailPage.y() + detailPage.height() // 4,
detailPage.width() // 2, detailPage.height() // 2)
final_rect = detailPage.geometry()
geometry_anim = QPropertyAnimation(detailPage, QByteArray(b"geometry"))
geometry_anim.setDuration(duration)
geometry_anim.setStartValue(initial_rect)
geometry_anim.setEndValue(final_rect)
geometry_anim.setEasingCurve(QEasingCurve.Type.OutBack)
group_anim = QParallelAnimationGroup()
group_anim.addAnimation(opacity_anim)
group_anim.addAnimation(geometry_anim)
def load_image_and_restore_effect():
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping image load")
return
# No need to restore graphics effect, just ensure full opacity
detailPage.setWindowOpacity(1.0)
if cover_path:
def on_pixmap_ready(pixmap):
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping pixmap update")
return
rounded = round_corners(pixmap, 10)
imageLabel.setPixmap(rounded)
logger.debug("Pixmap set for imageLabel")
def on_palette_ready(palette):
if not detailPage or detailPage.isHidden():
logger.warning("Detail page is None or hidden, skipping palette update")
return
dark_palette = [self.darkenColor(color, factor=200) for color in palette]
stops = ",\n".join(
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
)
detailPage.setStyleSheet(self.theme.detail_page_style(stops))
logger.debug("Stylesheet updated with palette")
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
# Clean up function
def cleanup_animation():
if detailPage in self._animations:
del self._animations[detailPage]
group_anim.finished.connect(load_image_and_restore_effect)
group_anim.finished.connect(cleanup_animation)
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self._animations[detailPage] = group_anim
elif animation_type == "none":
pass
def toggleFavoriteInDetailPage(self, game_name, label): def toggleFavoriteInDetailPage(self, game_name, label):
favorites = read_favorites() favorites = read_favorites()

View File

@@ -291,7 +291,7 @@ def load_steam_apps_async(callback: Callable[[list], None]):
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar) logger.info("Archive %s deleted after extraction", cache_tar)
steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or [] steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps)) logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
@@ -303,12 +303,25 @@ def load_steam_apps_async(callback: Callable[[list], None]):
try: try:
with open(cache_json, "rb") as f: with open(cache_json, "rb") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or [] # Validate JSON structure
if not isinstance(data, list):
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure")
# Validate each app entry
for app in data:
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
raise ValueError("Invalid app entry structure")
steam_apps = data
logger.info("Loaded %d apps from cache", len(steam_apps)) logger.info("Loaded %d apps from cache", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
logger.error("Error reading cached JSON: %s", e) logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
callback([]) # Attempt to re-download if cache is invalid or corrupted
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
)
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else: else:
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
@@ -448,12 +461,25 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
try: try:
with open(cache_json, "rb") as f: with open(cache_json, "rb") as f:
data = orjson.loads(f.read()) data = orjson.loads(f.read())
anti_cheat_data = data or [] # Validate JSON structure
if not isinstance(data, list):
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure")
# Validate each anti-cheat entry
for entry in data:
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
raise ValueError("Invalid anti-cheat entry structure")
anti_cheat_data = data
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
except Exception as e: except Exception as e:
logger.error("Error reading cached WeAntiCheatYet JSON: %s", e) logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
callback([]) # Attempt to re-download if cache is invalid or corrupted
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else: else:
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"

View File

@@ -9,6 +9,10 @@ favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60 pixmapsScaledSize = 60, 60
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Тип анимации fade при входе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce", "none"
"detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса). # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена. # Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях. # Значение в пикселях.
@@ -75,7 +79,16 @@ GAME_CARD_ANIMATION = {
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный) {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану) {"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
] ],
# Длительность анимации fade при входе на детальную страницу
"detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу
"detail_page_slide_duration": 500,
# Длительность анимации zoom при входе на детальную страницу
"detail_page_zoom_duration": 400
} }
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА # СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА

View File

@@ -27,6 +27,10 @@ color_g = "rgba(0, 0, 0, 0)"
color_h = "transparent" color_h = "transparent"
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Тип анимации fade при входе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce", "none"
"detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса). # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена. # Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях. # Значение в пикселях.
@@ -93,7 +97,16 @@ GAME_CARD_ANIMATION = {
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный) {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану) {"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
] ],
# Длительность анимации fade при входе на детальную страницу
"detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу
"detail_page_slide_duration": 500,
# Длительность анимации zoom при входе на детальную страницу
"detail_page_zoom_duration": 400
} }
CONTEXT_MENU_STYLE = f""" CONTEXT_MENU_STYLE = f"""