forked from Boria138/PortProtonQt
Compare commits
18 Commits
5bfd23995c
...
a3d7351e16
Author | SHA1 | Date | |
---|---|---|---|
a3d7351e16
|
|||
fe208f0783
|
|||
b317e4760b
|
|||
6d3e0982c9
|
|||
372832b41d
|
|||
58a01d36fb
|
|||
5d84dbad8e
|
|||
61964d21c7
|
|||
2971a594dc
|
|||
a31c9dc186
|
|||
768d437dda
|
|||
ec3db0e1f2
|
|||
de3989dfbc
|
|||
a930cbd705
|
|||
e3fbe22ac0
|
|||
f4b65e9f38
|
|||
6885482aea
|
|||
77a7b3240e
|
@@ -145,14 +145,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: release/
|
path: release/
|
||||||
|
|
||||||
- name: Get Changes between Tags
|
- name: Extract changelog for version
|
||||||
id: changes
|
id: changelog
|
||||||
uses: https://github.com/simbo/changes-between-tags-action@v1
|
run: |
|
||||||
|
VERSION="${{ env.VERSION }}"
|
||||||
|
VERSION=${VERSION#v} # Remove 'v' prefix if present
|
||||||
|
awk "/^## \\[$VERSION\\]/ {flag=1; next} /^## \\[/ || /^---/ {flag=0} flag" CHANGELOG.md > changelog.txt
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
body: ${{ steps.changes.outputs.changes }}
|
body_path: changelog.txt
|
||||||
token: ${{ env.GITEA_TOKEN }}
|
token: ${{ env.GITEA_TOKEN }}
|
||||||
tag_name: ${{ env.VERSION }}
|
tag_name: ${{ env.VERSION }}
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
10
CHANGELOG.md
10
CHANGELOG.md
@@ -8,8 +8,9 @@
|
|||||||
### Added
|
### Added
|
||||||
- Кнопки сброса настроек и очистки кэша
|
- Кнопки сброса настроек и очистки кэша
|
||||||
- Начальная интеграция с EGS с помощью [Legendary](https://github.com/derrod/legendary)
|
- Начальная интеграция с EGS с помощью [Legendary](https://github.com/derrod/legendary)
|
||||||
|
- Бейдж EGS
|
||||||
|
- Бейдж PortProton
|
||||||
- Зависимость на `xdg-utils`
|
- Зависимость на `xdg-utils`
|
||||||
- Установка ширины бейджа в две трети ширины карточки
|
|
||||||
- Интеграция статуса WeAntiCheatYet в карточку
|
- Интеграция статуса WeAntiCheatYet в карточку
|
||||||
- Стили в AddGameDialog
|
- Стили в AddGameDialog
|
||||||
- Переключение полноэкранного режима через F11
|
- Переключение полноэкранного режима через F11
|
||||||
@@ -22,15 +23,19 @@
|
|||||||
- Пункт в контекстное меню "Удалить из Steam”
|
- Пункт в контекстное меню "Удалить из Steam”
|
||||||
- Метод сортировки сначала избранное
|
- Метод сортировки сначала избранное
|
||||||
- Авто сборки для тестирования
|
- Авто сборки для тестирования
|
||||||
- Благодарности контрибьюторам в README
|
- При подключении геймпада программа сама переходит в полноэкранный режим, а при выключении выходит
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Обновлены все иконки
|
- Обновлены все иконки
|
||||||
- Переименован `_get_steam_home` → `get_steam_home`
|
- Переименован `_get_steam_home` → `get_steam_home`
|
||||||
|
- Переименован `steam_game` → `game_source`
|
||||||
- Догика контекстного меню вынесена в `ContextMenuManager`
|
- Догика контекстного меню вынесена в `ContextMenuManager`
|
||||||
- Бейдж Steam теперь открывает Steam Community
|
- Бейдж Steam теперь открывает Steam Community
|
||||||
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
|
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
|
||||||
- Оптимизирована генерация карточек для предотвращения лагов при поиске и изменения размера окна
|
- Оптимизирована генерация карточек для предотвращения лагов при поиске и изменения размера окна
|
||||||
|
- Бейджи с карточек так же теперь дублируются и на странице с деталями, а не только в библиотеке
|
||||||
|
- Установка ширины бейджа в две трети ширины карточки
|
||||||
|
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Обработка несуществующей темы с возвратом к “standart”
|
- Обработка несуществующей темы с возвратом к “standart”
|
||||||
@@ -41,6 +46,7 @@
|
|||||||
- Ссылки на документацию в README
|
- Ссылки на документацию в README
|
||||||
- traceback при загрузке placeholder при отсутствии обложек
|
- traceback при загрузке placeholder при отсутствии обложек
|
||||||
- Утечки памяти при загрузке обложек
|
- Утечки памяти при загрузке обложек
|
||||||
|
- Ошибки при подключении геймпада из-за работы в разных потоках
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -35,14 +35,14 @@
|
|||||||
- [X] Добавить в карточку игры сведения о поддержке геймадов
|
- [X] Добавить в карточку игры сведения о поддержке геймадов
|
||||||
- [X] Добавить в карточки данные с ProtonDB
|
- [X] Добавить в карточки данные с ProtonDB
|
||||||
- [X] Добавить в карточки данные с Are We Anti-Cheat Yet?
|
- [X] Добавить в карточки данные с Are We Anti-Cheat Yet?
|
||||||
- [ ] Продублировать бейджы с карточки на страницу с деталями игрыы
|
- [X] Продублировать бейджы с карточки на страницу с деталями игрыы
|
||||||
- [X] Добавить парсинг ярлыков со Steam
|
- [X] Добавить парсинг ярлыков со Steam
|
||||||
- [X] Добавить парсинг ярлыков с EGS
|
- [X] Добавить парсинг ярлыков с EGS
|
||||||
- [ ] Избавится от бинарника legendary
|
- [ ] Избавится от бинарника legendary
|
||||||
- [ ] Добавить запуск и скачивание игр с EGS
|
- [ ] Добавить запуск и скачивание игр с EGS
|
||||||
- [ ] Добавить авторизацию в EGS через WebView, а не вручную
|
- [ ] Добавить авторизацию в EGS через WebView, а не вручную
|
||||||
- [X] Брать описания для игр с EGS из их [api](https://store-content.ak.epicgames.com/api)
|
- [X] Брать описания для игр с EGS из их [api](https://store-content.ak.epicgames.com/api)
|
||||||
- [ ] Брать slug через Graphql [запрос](https://launcher.store.epicgames.com/graphql)
|
- [X] Брать slug через Graphql [запрос](https://launcher.store.epicgames.com/graphql)
|
||||||
- [X] Добавить на карточку бейдж того что игра со стима
|
- [X] Добавить на карточку бейдж того что игра со стима
|
||||||
- [X] Добавить поддержку Flatpak и Snap версии Steam
|
- [X] Добавить поддержку Flatpak и Snap версии Steam
|
||||||
- [X] Выводить данные о самом недавнем пользователе Steam, а не первом попавшемся
|
- [X] Выводить данные о самом недавнем пользователе Steam, а не первом попавшемся
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
- [X] Исправить частичное применение тем на лету
|
- [X] Исправить частичное применение тем на лету
|
||||||
- [X] Исправить наложение подписей скриншотов при первом перелистывание в полноэкранном режиме
|
- [X] Исправить наложение подписей скриншотов при первом перелистывание в полноэкранном режиме
|
||||||
- [ ] Добавить GOG (?)
|
- [ ] Добавить GOG (?)
|
||||||
|
- [ ] Определится уже наконец с названием (PortProtonQt или PortProtonQT)
|
||||||
|
|
||||||
### Установка (debug)
|
### Установка (debug)
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
# Base directory of the project
|
# Base directory of the project
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
@@ -13,6 +14,7 @@ FEDORA_SPEC = BASE_DIR / "build-aux" / "fedora.spec"
|
|||||||
PYPROJECT = BASE_DIR / "pyproject.toml"
|
PYPROJECT = BASE_DIR / "pyproject.toml"
|
||||||
APP_PY = BASE_DIR / "portprotonqt" / "app.py"
|
APP_PY = BASE_DIR / "portprotonqt" / "app.py"
|
||||||
GITEA_WORKFLOW = BASE_DIR / ".gitea" / "workflows" / "build.yml"
|
GITEA_WORKFLOW = BASE_DIR / ".gitea" / "workflows" / "build.yml"
|
||||||
|
CHANGELOG = BASE_DIR / "CHANGELOG.md"
|
||||||
|
|
||||||
def bump_appimage(path: Path, old: str, new: str) -> bool:
|
def bump_appimage(path: Path, old: str, new: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -27,7 +29,6 @@ def bump_appimage(path: Path, old: str, new: str) -> bool:
|
|||||||
path.write_text(new_text, encoding='utf-8')
|
path.write_text(new_text, encoding='utf-8')
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
|
|
||||||
def bump_arch(path: Path, old: str, new: str) -> bool:
|
def bump_arch(path: Path, old: str, new: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update pkgver in PKGBUILD
|
Update pkgver in PKGBUILD
|
||||||
@@ -41,7 +42,6 @@ def bump_arch(path: Path, old: str, new: str) -> bool:
|
|||||||
path.write_text(new_text, encoding='utf-8')
|
path.write_text(new_text, encoding='utf-8')
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
|
|
||||||
def bump_fedora(path: Path, old: str, new: str) -> bool:
|
def bump_fedora(path: Path, old: str, new: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update only the '%global pypi_version' line in fedora.spec
|
Update only the '%global pypi_version' line in fedora.spec
|
||||||
@@ -55,7 +55,6 @@ def bump_fedora(path: Path, old: str, new: str) -> bool:
|
|||||||
path.write_text(new_text, encoding='utf-8')
|
path.write_text(new_text, encoding='utf-8')
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
|
|
||||||
def bump_pyproject(path: Path, old: str, new: str) -> bool:
|
def bump_pyproject(path: Path, old: str, new: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update version in pyproject.toml under [project]
|
Update version in pyproject.toml under [project]
|
||||||
@@ -69,7 +68,6 @@ def bump_pyproject(path: Path, old: str, new: str) -> bool:
|
|||||||
path.write_text(new_text, encoding='utf-8')
|
path.write_text(new_text, encoding='utf-8')
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
|
|
||||||
def bump_app_py(path: Path, old: str, new: str) -> bool:
|
def bump_app_py(path: Path, old: str, new: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update __app_version__ in app.py
|
Update __app_version__ in app.py
|
||||||
@@ -83,7 +81,6 @@ def bump_app_py(path: Path, old: str, new: str) -> bool:
|
|||||||
path.write_text(new_text, encoding='utf-8')
|
path.write_text(new_text, encoding='utf-8')
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
|
|
||||||
def bump_workflow(path: Path, old: str, new: str) -> bool:
|
def bump_workflow(path: Path, old: str, new: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Update VERSION in Gitea Actions workflow
|
Update VERSION in Gitea Actions workflow
|
||||||
@@ -97,6 +94,19 @@ def bump_workflow(path: Path, old: str, new: str) -> bool:
|
|||||||
path.write_text(new_text, encoding='utf-8')
|
path.write_text(new_text, encoding='utf-8')
|
||||||
return bool(count)
|
return bool(count)
|
||||||
|
|
||||||
|
def bump_changelog(path: Path, old: str, new: str) -> bool:
|
||||||
|
"""
|
||||||
|
Update [Unreleased] to [new] - YYYY-MM-DD in CHANGELOG.md
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
text = path.read_text(encoding='utf-8')
|
||||||
|
pattern = re.compile(r"(?m)^##\s*\[Unreleased\]$")
|
||||||
|
current_date = date.today().strftime('%Y-%m-%d')
|
||||||
|
new_text, count = pattern.subn(f"## [{new}] - {current_date}", text)
|
||||||
|
if count:
|
||||||
|
path.write_text(new_text, encoding='utf-8')
|
||||||
|
return bool(count)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description='Bump project version in specific files')
|
parser = argparse.ArgumentParser(description='Bump project version in specific files')
|
||||||
@@ -111,7 +121,8 @@ def main():
|
|||||||
(FEDORA_SPEC, bump_fedora),
|
(FEDORA_SPEC, bump_fedora),
|
||||||
(PYPROJECT, bump_pyproject),
|
(PYPROJECT, bump_pyproject),
|
||||||
(APP_PY, bump_app_py),
|
(APP_PY, bump_app_py),
|
||||||
(GITEA_WORKFLOW, bump_workflow)
|
(GITEA_WORKFLOW, bump_workflow),
|
||||||
|
(CHANGELOG, bump_changelog)
|
||||||
]
|
]
|
||||||
|
|
||||||
updated = []
|
updated = []
|
||||||
@@ -126,6 +137,5 @@ def main():
|
|||||||
else:
|
else:
|
||||||
print(f"No occurrences of version {old} found in specified files.")
|
print(f"No occurrences of version {old} found in specified files.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
main()
|
||||||
|
@@ -40,7 +40,7 @@ class ContextMenuManager:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
menu = QMenu(self.parent)
|
menu = QMenu(self.parent)
|
||||||
if game_card.steam_game != "true":
|
if game_card.game_source not in ("steam", "epic"):
|
||||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
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")
|
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||||
if os.path.exists(desktop_path):
|
if os.path.exists(desktop_path):
|
||||||
|
@@ -133,7 +133,7 @@ class FlowLayout(QLayout):
|
|||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
clicked = Signal()
|
clicked = Signal()
|
||||||
|
|
||||||
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, **kwargs):
|
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, change_cursor=True, **kwargs):
|
||||||
"""
|
"""
|
||||||
Поддерживаются вызовы:
|
Поддерживаются вызовы:
|
||||||
- ClickableLabel("текст", parent=...) – первый аргумент строка,
|
- ClickableLabel("текст", parent=...) – первый аргумент строка,
|
||||||
@@ -143,6 +143,7 @@ class ClickableLabel(QLabel):
|
|||||||
icon: QIcon или None – иконка, которая будет отрисована вместе с текстом.
|
icon: QIcon или None – иконка, которая будет отрисована вместе с текстом.
|
||||||
icon_size: int – размер иконки (ширина и высота).
|
icon_size: int – размер иконки (ширина и высота).
|
||||||
icon_space: int – отступ между иконкой и текстом.
|
icon_space: int – отступ между иконкой и текстом.
|
||||||
|
change_cursor: bool – изменять ли курсор на PointingHandCursor при наведении (по умолчанию True).
|
||||||
"""
|
"""
|
||||||
if args and isinstance(args[0], str):
|
if args and isinstance(args[0], str):
|
||||||
text = args[0]
|
text = args[0]
|
||||||
@@ -161,6 +162,7 @@ class ClickableLabel(QLabel):
|
|||||||
self._icon = icon
|
self._icon = icon
|
||||||
self._icon_size = icon_size
|
self._icon_size = icon_size
|
||||||
self._icon_space = icon_space
|
self._icon_space = icon_space
|
||||||
|
if change_cursor:
|
||||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
|
||||||
def setIcon(self, icon):
|
def setIcon(self, icon):
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
|
import re
|
||||||
import requests
|
import requests
|
||||||
|
import requests.exceptions
|
||||||
import threading
|
import threading
|
||||||
import orjson
|
import orjson
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,20 +29,20 @@ def get_cache_dir() -> Path:
|
|||||||
def get_egs_game_description_async(
|
def get_egs_game_description_async(
|
||||||
app_name: str,
|
app_name: str,
|
||||||
callback: Callable[[str], None],
|
callback: Callable[[str], None],
|
||||||
|
namespace: str | None = None,
|
||||||
cache_ttl: int = 3600
|
cache_ttl: int = 3600
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Asynchronously fetches the game description from the Epic Games Store API.
|
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.
|
Prioritizes GraphQL API with namespace for slug and description.
|
||||||
Checks the cache first; if the description is cached and not expired, returns it.
|
Falls back to legacy API if GraphQL provides a slug but no description.
|
||||||
Prioritizes the page with type 'productHome' for the base game description.
|
Caches results in ~/.cache/PortProtonQT/egs_app_{app_name}.json.
|
||||||
|
Handles DNS resolution failures gracefully.
|
||||||
"""
|
"""
|
||||||
cache_dir = get_cache_dir()
|
cache_dir = get_cache_dir()
|
||||||
cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json"
|
cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json"
|
||||||
|
|
||||||
# Initialize content to avoid unbound variable
|
# Check cache
|
||||||
content = b""
|
|
||||||
# Load existing cache
|
|
||||||
if cache_file.exists():
|
if cache_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(cache_file, "rb") as f:
|
with open(cache_file, "rb") as f:
|
||||||
@@ -70,10 +72,6 @@ def get_egs_game_description_async(
|
|||||||
app_name,
|
app_name,
|
||||||
str(e)
|
str(e)
|
||||||
)
|
)
|
||||||
logger.debug(
|
|
||||||
"Cache file content (first 100 chars): %s",
|
|
||||||
content[:100].decode('utf-8', errors='replace')
|
|
||||||
)
|
|
||||||
cache_file.unlink(missing_ok=True)
|
cache_file.unlink(missing_ok=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -84,88 +82,205 @@ def get_egs_game_description_async(
|
|||||||
cache_file.unlink(missing_ok=True)
|
cache_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
lang = get_egs_language()
|
lang = get_egs_language()
|
||||||
slug = app_name.lower().replace(":", "").replace(" ", "-")
|
headers = {
|
||||||
url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{slug}"
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) EpicGamesLauncher"
|
||||||
|
}
|
||||||
|
|
||||||
def fetch_description():
|
def slug_from_title(title: str) -> str:
|
||||||
|
"""Derives a slug from the game title, preserving numbers and handling special characters."""
|
||||||
|
# Keep letters, numbers, and spaces; replace spaces with hyphens
|
||||||
|
cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip()
|
||||||
|
return re.sub(r'\s+', '-', cleaned)
|
||||||
|
|
||||||
|
def get_product_slug(namespace: str) -> str:
|
||||||
|
"""Fetches the product slug using the namespace via GraphQL."""
|
||||||
|
search_query = {
|
||||||
|
"query": """
|
||||||
|
query {
|
||||||
|
Catalog {
|
||||||
|
catalogNs(namespace: $namespace) {
|
||||||
|
mappings(pageType: "productHome") {
|
||||||
|
pageSlug
|
||||||
|
pageType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"namespace": namespace}
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, timeout=5)
|
response = requests.post(
|
||||||
|
"https://launcher.store.epicgames.com/graphql",
|
||||||
|
json=search_query,
|
||||||
|
headers=headers,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = orjson.loads(response.content)
|
data = orjson.loads(response.content)
|
||||||
|
mappings = data.get("data", {}).get("Catalog", {}).get("catalogNs", {}).get("mappings", [])
|
||||||
|
for mapping in mappings:
|
||||||
|
if mapping.get("pageType") == "productHome":
|
||||||
|
return mapping.get("pageSlug", "")
|
||||||
|
logger.warning("No productHome slug found for namespace %s", namespace)
|
||||||
|
return ""
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Failed to fetch product slug for namespace %s: %s", namespace, str(e))
|
||||||
|
return ""
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
logger.warning("Invalid JSON response for namespace %s", namespace)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def fetch_legacy_description(url: str) -> str:
|
||||||
|
"""Fetches description from the legacy API, handling DNS failures."""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = orjson.loads(response.content)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
logger.warning("Invalid JSON structure for %s: %s", app_name, type(data))
|
logger.warning("Invalid JSON structure for %s in legacy API: %s", app_name, type(data))
|
||||||
callback("")
|
return ""
|
||||||
return
|
|
||||||
|
|
||||||
description = ""
|
|
||||||
pages = data.get("pages", [])
|
pages = data.get("pages", [])
|
||||||
if pages:
|
if pages:
|
||||||
# Look for the page with type "productHome" for the base game
|
|
||||||
for page in pages:
|
for page in pages:
|
||||||
if page.get("type") == "productHome":
|
if page.get("type") == "productHome":
|
||||||
about_data = page.get("data", {}).get("about", {})
|
return page.get("data", {}).get("about", {}).get("shortDescription", "")
|
||||||
description = about_data.get("shortDescription", "")
|
return pages[0].get("data", {}).get("about", {}).get("shortDescription", "")
|
||||||
break
|
return ""
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
logger.info("Legacy API returned 404 for %s", app_name)
|
||||||
else:
|
else:
|
||||||
# Fallback to first page's description if no productHome is found
|
logger.warning("HTTP error in legacy API for %s: %s", app_name, str(e))
|
||||||
description = (
|
return ""
|
||||||
pages[0].get("data", {})
|
except requests.exceptions.ConnectionError as e:
|
||||||
.get("about", {})
|
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
|
||||||
.get("shortDescription", "")
|
return ""
|
||||||
)
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
|
||||||
|
return ""
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
logger.warning("Invalid JSON response for %s in legacy API", app_name)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def fetch_graphql_description(namespace: str | None, locale: str) -> tuple[str, str]:
|
||||||
|
"""Fetches description and slug from GraphQL API using namespace or title."""
|
||||||
|
if namespace:
|
||||||
|
search_query = {
|
||||||
|
"query": """
|
||||||
|
query {
|
||||||
|
Product {
|
||||||
|
sandbox(sandboxId: $namespace) {
|
||||||
|
configuration {
|
||||||
|
... on StoreConfiguration {
|
||||||
|
configs {
|
||||||
|
shortDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catalogNs {
|
||||||
|
mappings(pageType: "productHome") {
|
||||||
|
pageSlug
|
||||||
|
pageType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"namespace": namespace}
|
||||||
|
}
|
||||||
|
url = "https://launcher.store.epicgames.com/graphql"
|
||||||
|
else:
|
||||||
|
search_query = {
|
||||||
|
"query": """
|
||||||
|
query search($keywords: String!, $locale: String) {
|
||||||
|
Catalog {
|
||||||
|
searchStore(keywords: $keywords, locale: $locale) {
|
||||||
|
elements { title namespace productSlug description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
"variables": {"keywords": app_name, "locale": locale}
|
||||||
|
}
|
||||||
|
url = "https://graphql.epicgames.com/graphql"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=search_query, headers=headers, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = orjson.loads(response.content)
|
||||||
|
if namespace:
|
||||||
|
configs = data.get("data", {}).get("Product", {}).get("sandbox", {}).get("configuration", [{}])[0].get("configs", {})
|
||||||
|
description = configs.get("shortDescription", "")
|
||||||
|
mappings = data.get("data", {}).get("Product", {}).get("catalogNs", {}).get("mappings", [])
|
||||||
|
slug = next((m.get("pageSlug", "") for m in mappings if m.get("pageType") == "productHome"), "")
|
||||||
|
return description, slug
|
||||||
|
else:
|
||||||
|
elements = data.get("data", {}).get("Catalog", {}).get("searchStore", {}).get("elements", [])
|
||||||
|
for element in elements:
|
||||||
|
if (isinstance(element, dict) and
|
||||||
|
element.get("title", "").lower() == app_name.lower() and
|
||||||
|
element.get("productSlug") and
|
||||||
|
not any(substring in element.get("title", "").lower()
|
||||||
|
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
|
||||||
|
return element.get("description", ""), element.get("productSlug", "")
|
||||||
|
return "", ""
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
|
||||||
|
return "", ""
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
logger.warning("Invalid JSON response for %s with locale %s", app_name, locale)
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
def fetch_description():
|
||||||
|
description = ""
|
||||||
|
product_slug = ""
|
||||||
|
|
||||||
|
# Step 1: Try GraphQL with namespace to get description and slug
|
||||||
|
if namespace:
|
||||||
|
description, product_slug = fetch_graphql_description(namespace, lang)
|
||||||
|
if description:
|
||||||
|
logger.debug("Fetched description from GraphQL for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
||||||
|
|
||||||
|
# Step 2: If no description or no namespace, try legacy API with slug
|
||||||
|
if not description:
|
||||||
|
if not product_slug:
|
||||||
|
product_slug = slug_from_title(app_name)
|
||||||
|
legacy_url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{product_slug}"
|
||||||
|
try:
|
||||||
|
description = fetch_legacy_description(legacy_url)
|
||||||
|
if description:
|
||||||
|
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
|
||||||
|
|
||||||
|
# Step 3: If still no description and no namespace, try GraphQL with title
|
||||||
|
if not description and not namespace:
|
||||||
|
description, _ = fetch_graphql_description(None, lang)
|
||||||
|
if description:
|
||||||
|
logger.debug("Fetched description from GraphQL title search for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
||||||
|
|
||||||
|
# Step 4: If no description found, log and return empty
|
||||||
if not description:
|
if not description:
|
||||||
logger.warning("No valid description found for %s", app_name)
|
logger.warning("No valid description found for %s", app_name)
|
||||||
|
|
||||||
logger.debug(
|
# Save to cache
|
||||||
"Fetched EGS description for %s: %s",
|
|
||||||
app_name,
|
|
||||||
(description[:100] + "...") if len(description) > 100 else description
|
|
||||||
)
|
|
||||||
|
|
||||||
cache_entry = {"description": description, "timestamp": time.time()}
|
cache_entry = {"description": description, "timestamp": time.time()}
|
||||||
try:
|
try:
|
||||||
temp_file = cache_file.with_suffix('.tmp')
|
temp_file = cache_file.with_suffix('.tmp')
|
||||||
with open(temp_file, "wb") as f:
|
with open(temp_file, "wb") as f:
|
||||||
f.write(orjson.dumps(cache_entry))
|
f.write(orjson.dumps(cache_entry))
|
||||||
temp_file.replace(cache_file)
|
temp_file.replace(cache_file)
|
||||||
logger.debug(
|
logger.debug("Saved description to cache for %s", app_name)
|
||||||
"Saved description to cache for %s", app_name
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error("Failed to save description cache for %s: %s", app_name, str(e))
|
||||||
"Failed to save description cache for %s: %s",
|
|
||||||
app_name,
|
|
||||||
str(e)
|
|
||||||
)
|
|
||||||
callback(description)
|
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(
|
thread = threading.Thread(target=fetch_description, daemon=True)
|
||||||
target=fetch_description,
|
|
||||||
daemon=True
|
|
||||||
)
|
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
||||||
"""
|
"""
|
||||||
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
||||||
|
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
|||||||
import portprotonqt.themes.standart.styles as default_styles
|
import portprotonqt.themes.standart.styles as default_styles
|
||||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.config_utils import read_favorites, save_favorites
|
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
|
||||||
from portprotonqt.theme_manager import ThemeManager
|
from portprotonqt.theme_manager import ThemeManager
|
||||||
from portprotonqt.config_utils import read_theme_from_config
|
from portprotonqt.config_utils import read_theme_from_config
|
||||||
from portprotonqt.custom_widgets import ClickableLabel
|
from portprotonqt.custom_widgets import ClickableLabel
|
||||||
@@ -27,7 +27,7 @@ class GameCard(QFrame):
|
|||||||
openGameFolderRequested = 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,
|
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,
|
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
|
||||||
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
|
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -40,7 +40,7 @@ class GameCard(QFrame):
|
|||||||
self.formatted_playtime = formatted_playtime
|
self.formatted_playtime = formatted_playtime
|
||||||
self.protondb_tier = protondb_tier
|
self.protondb_tier = protondb_tier
|
||||||
self.anticheat_status = anticheat_status
|
self.anticheat_status = anticheat_status
|
||||||
self.steam_game = steam_game
|
self.game_source = game_source
|
||||||
self.last_launch_ts = last_launch_ts
|
self.last_launch_ts = last_launch_ts
|
||||||
self.playtime_seconds = playtime_seconds
|
self.playtime_seconds = playtime_seconds
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ class GameCard(QFrame):
|
|||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.theme = theme if theme is not None else default_styles
|
self.theme = theme if theme is not None else default_styles
|
||||||
|
|
||||||
|
self.display_filter = read_display_filter()
|
||||||
self.current_theme_name = read_theme_from_config()
|
self.current_theme_name = read_theme_from_config()
|
||||||
|
|
||||||
# Дополнительное пространство для анимации
|
# Дополнительное пространство для анимации
|
||||||
@@ -105,7 +106,6 @@ class GameCard(QFrame):
|
|||||||
def on_cover_loaded(pixmap):
|
def on_cover_loaded(pixmap):
|
||||||
label = label_ref()
|
label = label_ref()
|
||||||
if label is None:
|
if label is None:
|
||||||
# QLabel уже удалён — ничего не делаем
|
|
||||||
return
|
return
|
||||||
label.setPixmap(round_corners(pixmap, 15))
|
label.setPixmap(round_corners(pixmap, 15))
|
||||||
|
|
||||||
@@ -121,6 +121,10 @@ class GameCard(QFrame):
|
|||||||
self.update_favorite_icon()
|
self.update_favorite_icon()
|
||||||
self.favoriteLabel.raise_()
|
self.favoriteLabel.raise_()
|
||||||
|
|
||||||
|
steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
||||||
|
egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||||
|
portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
||||||
|
|
||||||
# ProtonDB бейдж
|
# ProtonDB бейдж
|
||||||
tier_text = self.getProtonDBText(protondb_tier)
|
tier_text = self.getProtonDBText(protondb_tier)
|
||||||
if tier_text:
|
if tier_text:
|
||||||
@@ -134,11 +138,11 @@ class GameCard(QFrame):
|
|||||||
icon_space=3,
|
icon_space=3,
|
||||||
)
|
)
|
||||||
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
||||||
self.protondbLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
|
self.protondbLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
protondb_visible = True
|
protondb_visible = True
|
||||||
else:
|
else:
|
||||||
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
||||||
self.protondbLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину даже для невидимого бейджа
|
self.protondbLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
self.protondbLabel.setVisible(False)
|
self.protondbLabel.setVisible(False)
|
||||||
protondb_visible = False
|
protondb_visible = False
|
||||||
|
|
||||||
@@ -152,10 +156,37 @@ class GameCard(QFrame):
|
|||||||
icon_space=5,
|
icon_space=5,
|
||||||
)
|
)
|
||||||
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
self.steamLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
|
self.steamLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
steam_visible = (str(steam_game).lower() == "true")
|
|
||||||
self.steamLabel.setVisible(steam_visible)
|
self.steamLabel.setVisible(steam_visible)
|
||||||
|
|
||||||
|
# Epic Games Store бейдж
|
||||||
|
egs_icon = self.theme_manager.get_icon("steam")
|
||||||
|
self.egsLabel = ClickableLabel(
|
||||||
|
"Epic Games",
|
||||||
|
icon=egs_icon,
|
||||||
|
parent=coverWidget,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
change_cursor=False
|
||||||
|
)
|
||||||
|
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
|
self.egsLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
|
self.egsLabel.setVisible(egs_visible)
|
||||||
|
|
||||||
|
# PortProton badge
|
||||||
|
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
||||||
|
self.portprotonLabel = ClickableLabel(
|
||||||
|
"PortProton",
|
||||||
|
icon=portproton_icon,
|
||||||
|
parent=coverWidget,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
change_cursor=False
|
||||||
|
)
|
||||||
|
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
|
self.portprotonLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
|
self.portprotonLabel.setVisible(portproton_visible)
|
||||||
|
|
||||||
# WeAntiCheatYet бейдж
|
# WeAntiCheatYet бейдж
|
||||||
anticheat_text = self.getAntiCheatText(anticheat_status)
|
anticheat_text = self.getAntiCheatText(anticheat_status)
|
||||||
if anticheat_text:
|
if anticheat_text:
|
||||||
@@ -169,11 +200,11 @@ class GameCard(QFrame):
|
|||||||
icon_space=3,
|
icon_space=3,
|
||||||
)
|
)
|
||||||
self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
|
self.anticheatLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
anticheat_visible = True
|
anticheat_visible = True
|
||||||
else:
|
else:
|
||||||
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
||||||
self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину даже для невидимого бейджа
|
self.anticheatLabel.setFixedWidth(int(card_width * 2/3))
|
||||||
self.anticheatLabel.setVisible(False)
|
self.anticheatLabel.setVisible(False)
|
||||||
anticheat_visible = False
|
anticheat_visible = False
|
||||||
|
|
||||||
@@ -182,11 +213,21 @@ class GameCard(QFrame):
|
|||||||
badge_spacing = 5
|
badge_spacing = 5
|
||||||
top_y = 10
|
top_y = 10
|
||||||
badge_y_positions = []
|
badge_y_positions = []
|
||||||
badge_width = int(card_width * 2/3) # Фиксированная ширина бейджей
|
badge_width = int(card_width * 2/3)
|
||||||
if steam_visible:
|
if steam_visible:
|
||||||
steam_x = card_width - badge_width - right_margin
|
steam_x = card_width - badge_width - right_margin
|
||||||
self.steamLabel.move(steam_x, top_y)
|
self.steamLabel.move(steam_x, top_y)
|
||||||
badge_y_positions.append(top_y + self.steamLabel.height())
|
badge_y_positions.append(top_y + self.steamLabel.height())
|
||||||
|
if egs_visible:
|
||||||
|
egs_x = card_width - badge_width - right_margin
|
||||||
|
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
self.egsLabel.move(egs_x, egs_y)
|
||||||
|
badge_y_positions.append(egs_y + self.egsLabel.height())
|
||||||
|
if portproton_visible:
|
||||||
|
portproton_x = card_width - badge_width - right_margin
|
||||||
|
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
self.portprotonLabel.move(portproton_x, portproton_y)
|
||||||
|
badge_y_positions.append(portproton_y + self.portprotonLabel.height())
|
||||||
if protondb_visible:
|
if protondb_visible:
|
||||||
protondb_x = card_width - badge_width - right_margin
|
protondb_x = card_width - badge_width - right_margin
|
||||||
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
@@ -199,6 +240,8 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
self.anticheatLabel.raise_()
|
self.anticheatLabel.raise_()
|
||||||
self.protondbLabel.raise_()
|
self.protondbLabel.raise_()
|
||||||
|
self.portprotonLabel.raise_()
|
||||||
|
self.egsLabel.raise_()
|
||||||
self.steamLabel.raise_()
|
self.steamLabel.raise_()
|
||||||
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
||||||
self.steamLabel.clicked.connect(self.open_steam_page)
|
self.steamLabel.clicked.connect(self.open_steam_page)
|
||||||
@@ -212,12 +255,60 @@ class GameCard(QFrame):
|
|||||||
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||||
layout.addWidget(nameLabel)
|
layout.addWidget(nameLabel)
|
||||||
|
|
||||||
|
def update_badge_visibility(self, display_filter: str):
|
||||||
|
"""Update badge visibility based on the provided display_filter."""
|
||||||
|
self.display_filter = display_filter
|
||||||
|
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||||
|
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||||
|
self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
|
||||||
|
|
||||||
|
self.steamLabel.setVisible(self.steam_visible)
|
||||||
|
self.egsLabel.setVisible(self.egs_visible)
|
||||||
|
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||||
|
|
||||||
|
# Reposition badges
|
||||||
|
right_margin = 8
|
||||||
|
badge_spacing = 5
|
||||||
|
top_y = 10
|
||||||
|
badge_y_positions = []
|
||||||
|
badge_width = int(self.coverLabel.width() * 2/3)
|
||||||
|
if self.steam_visible:
|
||||||
|
steam_x = self.coverLabel.width() - badge_width - right_margin
|
||||||
|
self.steamLabel.move(steam_x, top_y)
|
||||||
|
badge_y_positions.append(top_y + self.steamLabel.height())
|
||||||
|
if self.egs_visible:
|
||||||
|
egs_x = self.coverLabel.width() - badge_width - right_margin
|
||||||
|
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
self.egsLabel.move(egs_x, egs_y)
|
||||||
|
badge_y_positions.append(egs_y + self.egsLabel.height())
|
||||||
|
if self.portproton_visible:
|
||||||
|
portproton_x = self.coverLabel.width() - badge_width - right_margin
|
||||||
|
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
self.portprotonLabel.move(portproton_x, portproton_y)
|
||||||
|
badge_y_positions.append(portproton_y + self.portprotonLabel.height())
|
||||||
|
if self.protondbLabel.isVisible():
|
||||||
|
protondb_x = self.coverLabel.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 self.anticheatLabel.isVisible():
|
||||||
|
anticheat_x = self.coverLabel.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.portprotonLabel.raise_()
|
||||||
|
self.egsLabel.raise_()
|
||||||
|
self.steamLabel.raise_()
|
||||||
|
|
||||||
def _show_context_menu(self, pos):
|
def _show_context_menu(self, pos):
|
||||||
"""Delegate context menu display to ContextMenuManager."""
|
"""Delegate context menu display to ContextMenuManager."""
|
||||||
if self.context_menu_manager:
|
if self.context_menu_manager:
|
||||||
self.context_menu_manager.show_context_menu(self, pos)
|
self.context_menu_manager.show_context_menu(self, pos)
|
||||||
|
|
||||||
def getAntiCheatText(self, status):
|
@staticmethod
|
||||||
|
def getAntiCheatText(status: str) -> str:
|
||||||
if not status:
|
if not status:
|
||||||
return ""
|
return ""
|
||||||
translations = {
|
translations = {
|
||||||
@@ -229,7 +320,8 @@ class GameCard(QFrame):
|
|||||||
}
|
}
|
||||||
return translations.get(status.lower(), "")
|
return translations.get(status.lower(), "")
|
||||||
|
|
||||||
def getAntiCheatIconFilename(self, status):
|
@staticmethod
|
||||||
|
def getAntiCheatIconFilename(status: str) -> str:
|
||||||
status = status.lower()
|
status = status.lower()
|
||||||
if status in ("supported", "running"):
|
if status in ("supported", "running"):
|
||||||
return "platinum-gold"
|
return "platinum-gold"
|
||||||
@@ -237,7 +329,8 @@ class GameCard(QFrame):
|
|||||||
return "broken"
|
return "broken"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def getProtonDBText(self, tier):
|
@staticmethod
|
||||||
|
def getProtonDBText(tier: str) -> str:
|
||||||
if not tier:
|
if not tier:
|
||||||
return ""
|
return ""
|
||||||
translations = {
|
translations = {
|
||||||
@@ -250,7 +343,8 @@ class GameCard(QFrame):
|
|||||||
}
|
}
|
||||||
return translations.get(tier.lower(), "")
|
return translations.get(tier.lower(), "")
|
||||||
|
|
||||||
def getProtonDBIconFilename(self, tier):
|
@staticmethod
|
||||||
|
def getProtonDBIconFilename(tier: str) -> str:
|
||||||
tier = tier.lower()
|
tier = tier.lower()
|
||||||
if tier in ("platinum", "gold"):
|
if tier in ("platinum", "gold"):
|
||||||
return "platinum-gold"
|
return "platinum-gold"
|
||||||
@@ -451,7 +545,8 @@ class GameCard(QFrame):
|
|||||||
self.last_launch,
|
self.last_launch,
|
||||||
self.formatted_playtime,
|
self.formatted_playtime,
|
||||||
self.protondb_tier,
|
self.protondb_tier,
|
||||||
self.steam_game
|
self.game_source,
|
||||||
|
self.anticheat_status
|
||||||
)
|
)
|
||||||
super().mousePressEvent(event)
|
super().mousePressEvent(event)
|
||||||
|
|
||||||
@@ -467,7 +562,8 @@ class GameCard(QFrame):
|
|||||||
self.last_launch,
|
self.last_launch,
|
||||||
self.formatted_playtime,
|
self.formatted_playtime,
|
||||||
self.protondb_tier,
|
self.protondb_tier,
|
||||||
self.steam_game
|
self.game_source,
|
||||||
|
self.anticheat_status
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event)
|
||||||
|
@@ -4,13 +4,13 @@ from typing import Protocol, cast
|
|||||||
from evdev import InputDevice, ecodes, list_devices
|
from evdev import InputDevice, ecodes, list_devices
|
||||||
import pyudev
|
import pyudev
|
||||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit
|
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit
|
||||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint
|
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot
|
||||||
from PySide6.QtGui import QKeyEvent
|
from PySide6.QtGui import QKeyEvent
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
from portprotonqt.image_utils import FullscreenDialog
|
from portprotonqt.image_utils import FullscreenDialog
|
||||||
from portprotonqt.custom_widgets import NavLabel
|
from portprotonqt.custom_widgets import NavLabel
|
||||||
from portprotonqt.game_card import GameCard
|
from portprotonqt.game_card import GameCard
|
||||||
from portprotonqt.config_utils import read_fullscreen_config
|
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -31,23 +31,15 @@ class MainWindowProtocol(Protocol):
|
|||||||
currentDetailPage: QWidget | None
|
currentDetailPage: QWidget | None
|
||||||
current_exec_line: str | 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)
|
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers
|
||||||
BUTTONS = {
|
BUTTONS = {
|
||||||
# South button: X (PlayStation), A (Xbox), B (Switch Joy-Con south)
|
'confirm': {ecodes.BTN_A},
|
||||||
'confirm': {ecodes.BTN_SOUTH, ecodes.BTN_A},
|
'back': {ecodes.BTN_B},
|
||||||
# East button: Circle (PS), B (Xbox), A (Switch Joy-Con east)
|
'add_game': {ecodes.BTN_Y},
|
||||||
'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},
|
'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},
|
'next_tab': {ecodes.BTN_TR, ecodes.BTN_TR2},
|
||||||
# Optional: stick presses on Switch Joy-Con
|
|
||||||
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
|
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
|
||||||
# Start button for context menu
|
|
||||||
'context_menu': {ecodes.BTN_START},
|
'context_menu': {ecodes.BTN_START},
|
||||||
# Select/home for back/menu
|
|
||||||
'menu': {ecodes.BTN_SELECT, ecodes.BTN_MODE},
|
'menu': {ecodes.BTN_SELECT, ecodes.BTN_MODE},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +47,14 @@ class InputManager(QObject):
|
|||||||
"""
|
"""
|
||||||
Manages input from gamepads and keyboards for navigating the application interface.
|
Manages input from gamepads and keyboards for navigating the application interface.
|
||||||
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
||||||
for seamless UI interaction.
|
for seamless UI interaction. Enables fullscreen mode when a gamepad is connected
|
||||||
|
and restores normal mode when disconnected.
|
||||||
"""
|
"""
|
||||||
|
# Signals for gamepad events
|
||||||
|
button_pressed = Signal(int) # Signal for button presses
|
||||||
|
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
|
||||||
|
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
main_window: MainWindowProtocol,
|
main_window: MainWindowProtocol,
|
||||||
@@ -81,22 +79,48 @@ class InputManager(QObject):
|
|||||||
self.running = True
|
self.running = True
|
||||||
self._is_fullscreen = read_fullscreen_config()
|
self._is_fullscreen = read_fullscreen_config()
|
||||||
|
|
||||||
|
# Connect signals to slots
|
||||||
|
self.button_pressed.connect(self.handle_button_slot)
|
||||||
|
self.dpad_moved.connect(self.handle_dpad_slot)
|
||||||
|
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
|
||||||
|
|
||||||
# Install keyboard event filter
|
# Install keyboard event filter
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is not None:
|
if app is not None:
|
||||||
app.installEventFilter(self)
|
app.installEventFilter(self)
|
||||||
else:
|
|
||||||
logger.error("QApplication instance is None, cannot install event filter")
|
|
||||||
|
|
||||||
# Initialize evdev + hotplug
|
# Initialize evdev + hotplug
|
||||||
self.init_gamepad()
|
self.init_gamepad()
|
||||||
|
|
||||||
|
@Slot(bool)
|
||||||
|
def handle_fullscreen_slot(self, enable: bool) -> None:
|
||||||
|
try:
|
||||||
|
if read_fullscreen_config():
|
||||||
|
return
|
||||||
|
window = self._parent
|
||||||
|
if not isinstance(window, QWidget):
|
||||||
|
return
|
||||||
|
if enable and not self._is_fullscreen:
|
||||||
|
if not window.isFullScreen():
|
||||||
|
save_window_geometry(window.width(), window.height())
|
||||||
|
window.showFullScreen()
|
||||||
|
self._is_fullscreen = True
|
||||||
|
elif not enable and self._is_fullscreen:
|
||||||
|
window.showNormal()
|
||||||
|
width, height = read_window_geometry()
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
window.resize(width, height)
|
||||||
|
self._is_fullscreen = False
|
||||||
|
save_window_geometry(width, height)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if not app:
|
if not app:
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
# 1) Интересуют только нажатия клавиш
|
# Handle only key press events
|
||||||
if not (isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress):
|
if not (isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress):
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
@@ -105,17 +129,16 @@ class InputManager(QObject):
|
|||||||
focused = QApplication.focusWidget()
|
focused = QApplication.focusWidget()
|
||||||
popup = QApplication.activePopupWidget()
|
popup = QApplication.activePopupWidget()
|
||||||
|
|
||||||
# 2) Закрытие приложения по Ctrl+Q
|
# Close application with Ctrl+Q
|
||||||
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
|
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||||
app.quit()
|
app.quit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 3) Если открыт любой popup — не перехватываем ENTER, ESC и стрелки
|
# Skip navigation keys if a popup is open
|
||||||
if popup:
|
if popup:
|
||||||
# возвращаем False, чтобы событие пошло дальше в Qt и закрыло popup как нужно
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 4) Навигация в полноэкранном просмотре
|
# FullscreenDialog navigation
|
||||||
active_win = QApplication.activeWindow()
|
active_win = QApplication.activeWindow()
|
||||||
if isinstance(active_win, FullscreenDialog):
|
if isinstance(active_win, FullscreenDialog):
|
||||||
if key == Qt.Key.Key_Right:
|
if key == Qt.Key.Key_Right:
|
||||||
@@ -128,27 +151,25 @@ class InputManager(QObject):
|
|||||||
active_win.close()
|
active_win.close()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 5) На странице деталей Enter запускает/останавливает игру
|
# Launch/stop game on detail page
|
||||||
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
if self._parent.current_exec_line:
|
if self._parent.current_exec_line:
|
||||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 6) Открытие контекстного меню для GameCard
|
# Context menu for GameCard
|
||||||
if isinstance(focused, GameCard):
|
if isinstance(focused, GameCard):
|
||||||
if key == Qt.Key.Key_F10 and Qt.KeyboardModifier.ShiftModifier:
|
if key == Qt.Key.Key_F10 and Qt.KeyboardModifier.ShiftModifier:
|
||||||
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
||||||
focused._show_context_menu(pos)
|
focused._show_context_menu(pos)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 7) Навигация по карточкам в Library
|
# Navigation in Library tab
|
||||||
if self._parent.stackedWidget.currentIndex() == 0:
|
if self._parent.stackedWidget.currentIndex() == 0:
|
||||||
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
|
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
|
||||||
scroll_area = self._parent.gamesListWidget.parentWidget()
|
scroll_area = self._parent.gamesListWidget.parentWidget()
|
||||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
||||||
scroll_area = scroll_area.parentWidget()
|
scroll_area = scroll_area.parentWidget()
|
||||||
if not scroll_area:
|
|
||||||
logger.warning("No QScrollArea found for gamesListWidget")
|
|
||||||
|
|
||||||
if isinstance(focused, GameCard):
|
if isinstance(focused, GameCard):
|
||||||
current_index = game_cards.index(focused) if focused in game_cards else -1
|
current_index = game_cards.index(focused) if focused in game_cards else -1
|
||||||
@@ -184,7 +205,7 @@ class InputManager(QObject):
|
|||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 8) Переключение вкладок ←/→
|
# Tab switching with Left/Right keys
|
||||||
idx = self._parent.stackedWidget.currentIndex()
|
idx = self._parent.stackedWidget.currentIndex()
|
||||||
total = len(self._parent.tabButtons)
|
total = len(self._parent.tabButtons)
|
||||||
if key == Qt.Key.Key_Left and not isinstance(focused, GameCard):
|
if key == Qt.Key.Key_Left and not isinstance(focused, GameCard):
|
||||||
@@ -198,7 +219,7 @@ class InputManager(QObject):
|
|||||||
self._parent.tabButtons[new].setFocus()
|
self._parent.tabButtons[new].setFocus()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 9) Спуск в содержимое вкладки ↓
|
# Navigate down into tab content
|
||||||
if key == Qt.Key.Key_Down:
|
if key == Qt.Key.Key_Down:
|
||||||
if isinstance(focused, NavLabel):
|
if isinstance(focused, NavLabel):
|
||||||
page = self._parent.stackedWidget.currentWidget()
|
page = self._parent.stackedWidget.currentWidget()
|
||||||
@@ -212,15 +233,15 @@ class InputManager(QObject):
|
|||||||
focused.focusNextChild()
|
focused.focusNextChild()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 10) Подъём по содержимому вкладки ↑
|
# Navigate up through tab content
|
||||||
if key == Qt.Key.Key_Up:
|
if key == Qt.Key.Key_Up:
|
||||||
if isinstance(focused, NavLabel):
|
if isinstance(focused, NavLabel):
|
||||||
return True # Не даём уйти выше NavLabel
|
return True
|
||||||
if focused is not None:
|
if focused is not None:
|
||||||
focused.focusPreviousChild()
|
focused.focusPreviousChild()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 11) Общие: Activate, Back, Add
|
# General actions: Activate, Back, Add
|
||||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
self._parent.activateFocusedWidget()
|
self._parent.activateFocusedWidget()
|
||||||
return True
|
return True
|
||||||
@@ -235,18 +256,11 @@ class InputManager(QObject):
|
|||||||
self._parent.openAddGameDialog()
|
self._parent.openAddGameDialog()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 12) Переключение полноэкранного режима по F11
|
# Toggle fullscreen with F11
|
||||||
if key == Qt.Key.Key_F11:
|
if key == Qt.Key.Key_F11:
|
||||||
if read_fullscreen_config():
|
if read_fullscreen_config():
|
||||||
return True
|
return True
|
||||||
window = self._parent
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
if isinstance(window, QWidget):
|
|
||||||
if self._is_fullscreen:
|
|
||||||
window.showNormal()
|
|
||||||
self._is_fullscreen = False
|
|
||||||
else:
|
|
||||||
window.showFullScreen()
|
|
||||||
self._is_fullscreen = True
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
@@ -254,9 +268,10 @@ class InputManager(QObject):
|
|||||||
def init_gamepad(self) -> None:
|
def init_gamepad(self) -> None:
|
||||||
self.check_gamepad()
|
self.check_gamepad()
|
||||||
threading.Thread(target=self.run_udev_monitor, daemon=True).start()
|
threading.Thread(target=self.run_udev_monitor, daemon=True).start()
|
||||||
logger.info("Input support initialized with hotplug (evdev + pyudev)")
|
logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
|
||||||
|
|
||||||
def run_udev_monitor(self) -> None:
|
def run_udev_monitor(self) -> None:
|
||||||
|
try:
|
||||||
context = pyudev.Context()
|
context = pyudev.Context()
|
||||||
monitor = pyudev.Monitor.from_netlink(context)
|
monitor = pyudev.Monitor.from_netlink(context)
|
||||||
monitor.filter_by(subsystem='input')
|
monitor.filter_by(subsystem='input')
|
||||||
@@ -264,8 +279,11 @@ class InputManager(QObject):
|
|||||||
observer.start()
|
observer.start()
|
||||||
while self.running:
|
while self.running:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in udev monitor: {e}", exc_info=True)
|
||||||
|
|
||||||
def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
|
def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
|
||||||
|
try:
|
||||||
if action == 'add':
|
if action == 'add':
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
self.check_gamepad()
|
self.check_gamepad()
|
||||||
@@ -275,8 +293,13 @@ class InputManager(QObject):
|
|||||||
self.gamepad = None
|
self.gamepad = None
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
|
# Signal to exit fullscreen mode
|
||||||
|
self.toggle_fullscreen.emit(False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling udev event: {e}", exc_info=True)
|
||||||
|
|
||||||
def check_gamepad(self) -> None:
|
def check_gamepad(self) -> None:
|
||||||
|
try:
|
||||||
new_gamepad = self.find_gamepad()
|
new_gamepad = self.find_gamepad()
|
||||||
if new_gamepad and new_gamepad != self.gamepad:
|
if new_gamepad and new_gamepad != self.gamepad:
|
||||||
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
||||||
@@ -285,14 +308,22 @@ class InputManager(QObject):
|
|||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
|
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
|
||||||
self.gamepad_thread.start()
|
self.gamepad_thread.start()
|
||||||
|
# Signal to enter fullscreen mode
|
||||||
|
self.toggle_fullscreen.emit(True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking gamepad: {e}", exc_info=True)
|
||||||
|
|
||||||
def find_gamepad(self) -> InputDevice | None:
|
def find_gamepad(self) -> InputDevice | None:
|
||||||
|
try:
|
||||||
devices = [InputDevice(path) for path in list_devices()]
|
devices = [InputDevice(path) for path in list_devices()]
|
||||||
for device in devices:
|
for device in devices:
|
||||||
caps = device.capabilities()
|
caps = device.capabilities()
|
||||||
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
|
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
|
||||||
return device
|
return device
|
||||||
return None
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding gamepad: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
def monitor_gamepad(self) -> None:
|
def monitor_gamepad(self) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -305,16 +336,29 @@ class InputManager(QObject):
|
|||||||
continue
|
continue
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
if event.type == ecodes.EV_KEY and event.value == 1:
|
||||||
self.handle_button(event.code)
|
self.button_pressed.emit(event.code)
|
||||||
elif event.type == ecodes.EV_ABS:
|
elif event.type == ecodes.EV_ABS:
|
||||||
self.handle_dpad(event.code, event.value, now)
|
self.dpad_moved.emit(event.code, event.value, now)
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == 19: # ENODEV: No such device
|
||||||
|
logger.info("Gamepad disconnected during event loop")
|
||||||
|
else:
|
||||||
|
logger.error(f"OSError in gamepad monitoring: {e}", exc_info=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error accessing gamepad: {e}")
|
logger.error(f"Error in gamepad monitoring: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if self.gamepad:
|
||||||
|
try:
|
||||||
|
self.gamepad.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.gamepad = None
|
||||||
|
|
||||||
def handle_button(self, button_code: int) -> None:
|
@Slot(int)
|
||||||
|
def handle_button_slot(self, button_code: int) -> None:
|
||||||
|
try:
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if not app:
|
||||||
logger.error("QApplication instance is None")
|
|
||||||
return
|
return
|
||||||
active = QApplication.activeWindow()
|
active = QApplication.activeWindow()
|
||||||
focused = QApplication.focusWidget()
|
focused = QApplication.focusWidget()
|
||||||
@@ -357,11 +401,14 @@ class InputManager(QObject):
|
|||||||
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
||||||
self._parent.switchTab(idx)
|
self._parent.switchTab(idx)
|
||||||
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
def handle_dpad(self, code: int, value: int, current_time: float) -> None:
|
@Slot(int, int, float)
|
||||||
|
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
|
||||||
|
try:
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app is None:
|
if not app:
|
||||||
logger.error("QApplication instance is None")
|
|
||||||
return
|
return
|
||||||
active = QApplication.activeWindow()
|
active = QApplication.activeWindow()
|
||||||
|
|
||||||
@@ -375,13 +422,11 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
# Vertical navigation (DPAD up/down)
|
# Vertical navigation (DPAD up/down)
|
||||||
if code == ecodes.ABS_HAT0Y:
|
if code == ecodes.ABS_HAT0Y:
|
||||||
# ignore release
|
|
||||||
if value == 0:
|
if value == 0:
|
||||||
return
|
return
|
||||||
focused = QApplication.focusWidget()
|
focused = QApplication.focusWidget()
|
||||||
page = self._parent.stackedWidget.currentWidget()
|
page = self._parent.stackedWidget.currentWidget()
|
||||||
if value > 0:
|
if value > 0:
|
||||||
# down
|
|
||||||
if isinstance(focused, NavLabel):
|
if isinstance(focused, NavLabel):
|
||||||
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
||||||
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
||||||
@@ -392,7 +437,6 @@ class InputManager(QObject):
|
|||||||
focused.focusNextChild()
|
focused.focusNextChild()
|
||||||
return
|
return
|
||||||
elif value < 0 and focused:
|
elif value < 0 and focused:
|
||||||
# up
|
|
||||||
focused.focusPreviousChild()
|
focused.focusPreviousChild()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -411,8 +455,11 @@ class InputManager(QObject):
|
|||||||
self.trigger_dpad_movement(code, value)
|
self.trigger_dpad_movement(code, value)
|
||||||
self.last_move_time = current_time
|
self.last_move_time = current_time
|
||||||
self.current_axis_delay = self.repeat_axis_move_delay
|
self.current_axis_delay = self.repeat_axis_move_delay
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
def trigger_dpad_movement(self, code: int, value: int) -> None:
|
def trigger_dpad_movement(self, code: int, value: int) -> None:
|
||||||
|
try:
|
||||||
if code != ecodes.ABS_HAT0X:
|
if code != ecodes.ABS_HAT0X:
|
||||||
return
|
return
|
||||||
idx = self._parent.stackedWidget.currentIndex()
|
idx = self._parent.stackedWidget.currentIndex()
|
||||||
@@ -422,9 +469,15 @@ class InputManager(QObject):
|
|||||||
new = (idx + 1) % len(self._parent.tabButtons)
|
new = (idx + 1) % len(self._parent.tabButtons)
|
||||||
self._parent.switchTab(new)
|
self._parent.switchTab(new)
|
||||||
self._parent.tabButtons[new].setFocus(Qt.FocusReason.OtherFocusReason)
|
self._parent.tabButtons[new].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in trigger_dpad_movement: {e}", exc_info=True)
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
|
try:
|
||||||
self.running = False
|
self.running = False
|
||||||
|
if self.gamepad_thread:
|
||||||
|
self.gamepad_thread.join()
|
||||||
if self.gamepad:
|
if self.gamepad:
|
||||||
self.gamepad.close()
|
self.gamepad.close()
|
||||||
logger.info("Input support cleaned up")
|
except Exception as e:
|
||||||
|
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||||
|
@@ -61,6 +61,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
||||||
self.games_loaded.connect(self.on_games_loaded)
|
self.games_loaded.connect(self.on_games_loaded)
|
||||||
|
|
||||||
|
# Добавляем таймер для дебаунсинга сохранения настроек
|
||||||
|
self.settingsDebounceTimer = QTimer(self)
|
||||||
|
self.settingsDebounceTimer.setSingleShot(True)
|
||||||
|
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
|
||||||
|
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
|
||||||
|
|
||||||
read_time_config()
|
read_time_config()
|
||||||
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQT/legendary
|
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQT/legendary
|
||||||
self.legendary_config_path = os.path.join(
|
self.legendary_config_path = os.path.join(
|
||||||
@@ -308,7 +314,7 @@ class MainWindow(QMainWindow):
|
|||||||
'controller_support': '',
|
'controller_support': '',
|
||||||
'protondb_tier': '',
|
'protondb_tier': '',
|
||||||
'name': name,
|
'name': name,
|
||||||
'steam_game': 'true'
|
'game_source': 'steam'
|
||||||
}
|
}
|
||||||
last_launch = format_last_launch(datetime.fromtimestamp(last_played)) if last_played else _("Never")
|
last_launch = format_last_launch(datetime.fromtimestamp(last_played)) if last_played else _("Never")
|
||||||
steam_games.append((
|
steam_games.append((
|
||||||
@@ -324,7 +330,7 @@ class MainWindow(QMainWindow):
|
|||||||
info.get("anticheat_status", ""),
|
info.get("anticheat_status", ""),
|
||||||
last_played,
|
last_played,
|
||||||
playtime_seconds,
|
playtime_seconds,
|
||||||
"true"
|
"steam"
|
||||||
))
|
))
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
self.pending_games.append(None)
|
self.pending_games.append(None)
|
||||||
@@ -456,7 +462,6 @@ class MainWindow(QMainWindow):
|
|||||||
final_cover = (user_cover if user_cover else
|
final_cover = (user_cover if user_cover else
|
||||||
builtin_cover if builtin_cover else
|
builtin_cover if builtin_cover else
|
||||||
steam_info.get("cover", "") or entry.get("Icon", ""))
|
steam_info.get("cover", "") or entry.get("Icon", ""))
|
||||||
steam_game = "false"
|
|
||||||
callback((
|
callback((
|
||||||
final_name,
|
final_name,
|
||||||
final_desc,
|
final_desc,
|
||||||
@@ -470,7 +475,7 @@ class MainWindow(QMainWindow):
|
|||||||
steam_info.get("anticheat_status", ""),
|
steam_info.get("anticheat_status", ""),
|
||||||
get_last_launch_timestamp(exe_name) if exe_name else 0,
|
get_last_launch_timestamp(exe_name) if exe_name else 0,
|
||||||
playtime_seconds,
|
playtime_seconds,
|
||||||
steam_game
|
"portproton"
|
||||||
))
|
))
|
||||||
|
|
||||||
get_steam_game_info_async(desktop_name, exec_line, on_steam_info)
|
get_steam_game_info_async(desktop_name, exec_line, on_steam_info)
|
||||||
@@ -1102,9 +1107,18 @@ class MainWindow(QMainWindow):
|
|||||||
# Показываем сообщение
|
# Показываем сообщение
|
||||||
self.statusBar().showMessage(_("Cache cleared"), 3000)
|
self.statusBar().showMessage(_("Cache cleared"), 3000)
|
||||||
|
|
||||||
|
def applySettingsDelayed(self):
|
||||||
|
"""Applies settings with the new filter and updates the game list."""
|
||||||
|
read_time_config()
|
||||||
|
self.games = []
|
||||||
|
self.loadGames()
|
||||||
|
display_filter = read_display_filter()
|
||||||
|
for card in self.game_card_cache.values():
|
||||||
|
card.update_badge_visibility(display_filter)
|
||||||
|
|
||||||
def savePortProtonSettings(self):
|
def savePortProtonSettings(self):
|
||||||
"""
|
"""
|
||||||
Сохраняет параметры конфигурации в конфигурационный файл,
|
Сохраняет параметры конфигурации в конфигурационный файл.
|
||||||
"""
|
"""
|
||||||
time_idx = self.timeDetailCombo.currentIndex()
|
time_idx = self.timeDetailCombo.currentIndex()
|
||||||
time_key = self.time_keys[time_idx]
|
time_key = self.time_keys[time_idx]
|
||||||
@@ -1127,16 +1141,32 @@ class MainWindow(QMainWindow):
|
|||||||
fullscreen = self.fullscreenCheckBox.isChecked()
|
fullscreen = self.fullscreenCheckBox.isChecked()
|
||||||
save_fullscreen_config(fullscreen)
|
save_fullscreen_config(fullscreen)
|
||||||
|
|
||||||
# Перезагружаем настройки
|
for card in self.game_card_cache.values():
|
||||||
read_time_config()
|
card.update_badge_visibility(filter_key)
|
||||||
self.games = self.loadGames()
|
|
||||||
self.updateGameGrid()
|
if self.currentDetailPage and self.current_exec_line:
|
||||||
|
current_game = next((game for game in self.games if game[4] == self.current_exec_line), None)
|
||||||
|
if current_game:
|
||||||
|
self.stackedWidget.removeWidget(self.currentDetailPage)
|
||||||
|
self.currentDetailPage.deleteLater()
|
||||||
|
self.currentDetailPage = None
|
||||||
|
self.openGameDetailPage(*current_game)
|
||||||
|
|
||||||
|
self.settingsDebounceTimer.start()
|
||||||
|
|
||||||
self.settings_saved.emit()
|
self.settings_saved.emit()
|
||||||
|
|
||||||
if fullscreen:
|
if fullscreen:
|
||||||
self.showFullScreen()
|
self.showFullScreen()
|
||||||
else:
|
else:
|
||||||
|
if self.isFullScreen():
|
||||||
|
# Переходим в нормальный режим и восстанавливаем сохраненные размеры
|
||||||
|
width, height = read_window_geometry()
|
||||||
self.showNormal()
|
self.showNormal()
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
self.resize(width, height)
|
||||||
|
# Сохраняем геометрию только если окно не в полноэкранном режиме
|
||||||
|
if not self.isFullScreen():
|
||||||
save_window_geometry(self.width(), self.height())
|
save_window_geometry(self.width(), self.height())
|
||||||
|
|
||||||
self.statusBar().showMessage(_("Settings saved"), 3000)
|
self.statusBar().showMessage(_("Settings saved"), 3000)
|
||||||
@@ -1302,7 +1332,7 @@ class MainWindow(QMainWindow):
|
|||||||
def darkenColor(self, color, factor=200):
|
def darkenColor(self, color, factor=200):
|
||||||
return color.darker(factor)
|
return color.darker(factor)
|
||||||
|
|
||||||
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", steam_game=""):
|
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
|
||||||
detailPage = QWidget()
|
detailPage = QWidget()
|
||||||
self._animations = {}
|
self._animations = {}
|
||||||
imageLabel = QLabel()
|
imageLabel = QLabel()
|
||||||
@@ -1357,7 +1387,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
coverLayout.addWidget(imageLabel)
|
coverLayout.addWidget(imageLabel)
|
||||||
|
|
||||||
# Добавляем значок избранного поверх обложки в левом верхнем углу
|
# Значок избранного
|
||||||
favoriteLabelCover = ClickableLabel(coverFrame)
|
favoriteLabelCover = ClickableLabel(coverFrame)
|
||||||
favoriteLabelCover.setFixedSize(*self.theme.favoriteLabelSize)
|
favoriteLabelCover.setFixedSize(*self.theme.favoriteLabelSize)
|
||||||
favoriteLabelCover.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
favoriteLabelCover.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||||
@@ -1370,6 +1400,139 @@ class MainWindow(QMainWindow):
|
|||||||
favoriteLabelCover.move(8, 8)
|
favoriteLabelCover.move(8, 8)
|
||||||
favoriteLabelCover.raise_()
|
favoriteLabelCover.raise_()
|
||||||
|
|
||||||
|
# Добавляем бейджи (ProtonDB, Steam, PortProton, WeAntiCheatYet)
|
||||||
|
display_filter = read_display_filter()
|
||||||
|
steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||||
|
egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||||
|
portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
|
||||||
|
right_margin = 8
|
||||||
|
badge_spacing = 5
|
||||||
|
top_y = 10
|
||||||
|
badge_y_positions = []
|
||||||
|
badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px)
|
||||||
|
|
||||||
|
# ProtonDB бейдж
|
||||||
|
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
||||||
|
if protondb_text:
|
||||||
|
icon_filename = GameCard.getProtonDBIconFilename(protondb_tier)
|
||||||
|
icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name)
|
||||||
|
protondbLabel = ClickableLabel(
|
||||||
|
protondb_text,
|
||||||
|
icon=icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=3,
|
||||||
|
)
|
||||||
|
protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
||||||
|
protondbLabel.setFixedWidth(badge_width)
|
||||||
|
protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}")))
|
||||||
|
protondb_visible = True
|
||||||
|
else:
|
||||||
|
protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
|
||||||
|
protondbLabel.setFixedWidth(badge_width)
|
||||||
|
protondbLabel.setVisible(False)
|
||||||
|
protondb_visible = False
|
||||||
|
|
||||||
|
# Steam бейдж
|
||||||
|
steam_icon = self.theme_manager.get_icon("steam")
|
||||||
|
steamLabel = ClickableLabel(
|
||||||
|
"Steam",
|
||||||
|
icon=steam_icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
)
|
||||||
|
steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
|
steamLabel.setFixedWidth(badge_width)
|
||||||
|
steamLabel.setVisible(steam_visible)
|
||||||
|
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
|
||||||
|
|
||||||
|
# Epic Games Store бейдж
|
||||||
|
egs_icon = self.theme_manager.get_icon("steam")
|
||||||
|
egsLabel = ClickableLabel(
|
||||||
|
"Epic Games",
|
||||||
|
icon=egs_icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
change_cursor=False
|
||||||
|
)
|
||||||
|
egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
|
egsLabel.setFixedWidth(badge_width)
|
||||||
|
egsLabel.setVisible(egs_visible)
|
||||||
|
|
||||||
|
# PortProton badge
|
||||||
|
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
||||||
|
portprotonLabel = ClickableLabel(
|
||||||
|
"PortProton",
|
||||||
|
icon=portproton_icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=5,
|
||||||
|
change_cursor=False
|
||||||
|
)
|
||||||
|
portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
|
portprotonLabel.setFixedWidth(badge_width)
|
||||||
|
portprotonLabel.setVisible(portproton_visible)
|
||||||
|
|
||||||
|
# WeAntiCheatYet бейдж
|
||||||
|
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
|
||||||
|
if anticheat_text:
|
||||||
|
icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status)
|
||||||
|
icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name)
|
||||||
|
anticheatLabel = ClickableLabel(
|
||||||
|
anticheat_text,
|
||||||
|
icon=icon,
|
||||||
|
parent=coverFrame,
|
||||||
|
icon_size=16,
|
||||||
|
icon_space=3,
|
||||||
|
)
|
||||||
|
anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
|
anticheatLabel.setFixedWidth(badge_width)
|
||||||
|
anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
|
||||||
|
anticheat_visible = True
|
||||||
|
else:
|
||||||
|
anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
|
||||||
|
anticheatLabel.setFixedWidth(badge_width)
|
||||||
|
anticheatLabel.setVisible(False)
|
||||||
|
anticheat_visible = False
|
||||||
|
|
||||||
|
# Расположение бейджей
|
||||||
|
right_margin = 8
|
||||||
|
badge_spacing = 5
|
||||||
|
top_y = 10
|
||||||
|
badge_y_positions = []
|
||||||
|
badge_width = int(300 * 2/3)
|
||||||
|
if steam_visible:
|
||||||
|
steam_x = 300 - badge_width - right_margin
|
||||||
|
steamLabel.move(steam_x, top_y)
|
||||||
|
badge_y_positions.append(top_y + steamLabel.height())
|
||||||
|
if egs_visible:
|
||||||
|
egs_x = 300 - badge_width - right_margin
|
||||||
|
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
egsLabel.move(egs_x, egs_y)
|
||||||
|
badge_y_positions.append(egs_y + egsLabel.height())
|
||||||
|
if portproton_visible:
|
||||||
|
portproton_x = 300 - badge_width - right_margin
|
||||||
|
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
portprotonLabel.move(portproton_x, portproton_y)
|
||||||
|
badge_y_positions.append(portproton_y + portprotonLabel.height())
|
||||||
|
if protondb_visible:
|
||||||
|
protondb_x = 300 - badge_width - right_margin
|
||||||
|
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
protondbLabel.move(protondb_x, protondb_y)
|
||||||
|
badge_y_positions.append(protondb_y + protondbLabel.height())
|
||||||
|
if anticheat_visible:
|
||||||
|
anticheat_x = 300 - badge_width - right_margin
|
||||||
|
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
anticheatLabel.move(anticheat_x, anticheat_y)
|
||||||
|
|
||||||
|
anticheatLabel.raise_()
|
||||||
|
protondbLabel.raise_()
|
||||||
|
portprotonLabel.raise_()
|
||||||
|
egsLabel.raise_()
|
||||||
|
steamLabel.raise_()
|
||||||
|
|
||||||
contentFrameLayout.addWidget(coverFrame)
|
contentFrameLayout.addWidget(coverFrame)
|
||||||
|
|
||||||
# Детали игры (справа)
|
# Детали игры (справа)
|
||||||
@@ -1515,7 +1678,7 @@ class MainWindow(QMainWindow):
|
|||||||
focused_widget.last_launch,
|
focused_widget.last_launch,
|
||||||
focused_widget.formatted_playtime,
|
focused_widget.formatted_playtime,
|
||||||
focused_widget.protondb_tier,
|
focused_widget.protondb_tier,
|
||||||
focused_widget.steam_game
|
focused_widget.game_source
|
||||||
)
|
)
|
||||||
|
|
||||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||||
|
Reference in New Issue
Block a user