18 Commits

Author SHA1 Message Date
a3d7351e16 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-05 17:22:30 +05:00
fe208f0783 fix(input-manager): resolve threading error in gamepad events
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-05 17:19:32 +05:00
b317e4760b feat(build): use CHANGELOG.md for release notes instead of commit history
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 20:56:43 +05:00
6d3e0982c9 feat(bump_ver): add changelog version and date update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 20:40:25 +05:00
372832b41d chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 20:36:52 +05:00
58a01d36fb feat(game_card): show source badges only for “all” and “favorites” filters
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 20:34:11 +05:00
5d84dbad8e refactor: rename steam_game to game_source for better clarity
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 20:11:05 +05:00
61964d21c7 feat(ui): add PortProton badge to game cards and detail pages
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 19:57:30 +05:00
2971a594dc feat: add change_cursor parameter to ClickableLabel for EGS
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 19:39:24 +05:00
a31c9dc186 feat: added egs badge
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-04 09:50:24 +05:00
768d437dda feat: optimize get_egs_game_description_async to minimize API requests and handle DNS failures
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-03 20:48:41 +05:00
ec3db0e1f2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-03 14:29:37 +05:00
de3989dfbc chore(readme): update todo
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-03 14:27:19 +05:00
a930cbd705 feat(ui): add ProtonDB, Steam, and WeAntiCheatYet badges to game detail page
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-03 14:26:25 +05:00
e3fbe22ac0 fix: prioritize egs legacy api
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-03 10:29:39 +05:00
f4b65e9f38 fix(ui): prevent window size reset and add settings debounce
- Prevent window size reset by checking fullscreen state and restoring saved geometry.
- Add settingsDebounceTimer to delay game list updates, improving performance.
- Ensure display filter updates without requiring application restart.

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-03 09:41:09 +05:00
6885482aea chore(readme): update todo
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-02 22:36:27 +05:00
77a7b3240e feat: enhance get_egs_game_description_async to use GraphQL
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-02 22:34:37 +05:00
10 changed files with 772 additions and 323 deletions

View File

@@ -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

View File

@@ -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 при отсутствии обложек
- Утечки памяти при загрузке обложек - Утечки памяти при загрузке обложек
- Ошибки при подключении геймпада из-за работы в разных потоках
--- ---

View File

@@ -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)

View File

@@ -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()

View File

@@ -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):

View File

@@ -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):

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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: