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:
path: release/
- name: Get Changes between Tags
id: changes
uses: https://github.com/simbo/changes-between-tags-action@v1
- name: Extract changelog for version
id: changelog
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
uses: https://gitea.com/actions/gitea-release-action@v1
with:
body: ${{ steps.changes.outputs.changes }}
body_path: changelog.txt
token: ${{ env.GITEA_TOKEN }}
tag_name: ${{ env.VERSION }}
prerelease: true

View File

@@ -8,8 +8,9 @@
### Added
- Кнопки сброса настроек и очистки кэша
- Начальная интеграция с EGS с помощью [Legendary](https://github.com/derrod/legendary)
- Бейдж EGS
- Бейдж PortProton
- Зависимость на `xdg-utils`
- Установка ширины бейджа в две трети ширины карточки
- Интеграция статуса WeAntiCheatYet в карточку
- Стили в AddGameDialog
- Переключение полноэкранного режима через F11
@@ -22,15 +23,19 @@
- Пункт в контекстное меню "Удалить из Steam”
- Метод сортировки сначала избранное
- Авто сборки для тестирования
- Благодарности контрибьюторам в README
- При подключении геймпада программа сама переходит в полноэкранный режим, а при выключении выходит
### Changed
- Обновлены все иконки
- Переименован `_get_steam_home``get_steam_home`
- Переименован `steam_game``game_source`
- Догика контекстного меню вынесена в `ContextMenuManager`
- Бейдж Steam теперь открывает Steam Community
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
- Оптимизирована генерация карточек для предотвращения лагов при поиске и изменения размера окна
- Бейджи с карточек так же теперь дублируются и на странице с деталями, а не только в библиотеке
- Установка ширины бейджа в две трети ширины карточки
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
### Fixed
- Обработка несуществующей темы с возвратом к “standart”
@@ -41,6 +46,7 @@
- Ссылки на документацию в README
- traceback при загрузке placeholder при отсутствии обложек
- Утечки памяти при загрузке обложек
- Ошибки при подключении геймпада из-за работы в разных потоках
---

View File

@@ -35,14 +35,14 @@
- [X] Добавить в карточку игры сведения о поддержке геймадов
- [X] Добавить в карточки данные с ProtonDB
- [X] Добавить в карточки данные с Are We Anti-Cheat Yet?
- [ ] Продублировать бейджы с карточки на страницу с деталями игрыы
- [X] Продублировать бейджы с карточки на страницу с деталями игрыы
- [X] Добавить парсинг ярлыков со Steam
- [X] Добавить парсинг ярлыков с EGS
- [ ] Избавится от бинарника legendary
- [ ] Добавить запуск и скачивание игр с EGS
- [ ] Добавить авторизацию в EGS через WebView, а не вручную
- [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] Добавить поддержку Flatpak и Snap версии Steam
- [X] Выводить данные о самом недавнем пользователе Steam, а не первом попавшемся
@@ -58,6 +58,7 @@
- [X] Исправить частичное применение тем на лету
- [X] Исправить наложение подписей скриншотов при первом перелистывание в полноэкранном режиме
- [ ] Добавить GOG (?)
- [ ] Определится уже наконец с названием (PortProtonQt или PortProtonQT)
### Установка (debug)

View File

@@ -3,6 +3,7 @@
import argparse
import re
from pathlib import Path
from datetime import date
# Base directory of the project
BASE_DIR = Path(__file__).parent.parent
@@ -13,6 +14,7 @@ FEDORA_SPEC = BASE_DIR / "build-aux" / "fedora.spec"
PYPROJECT = BASE_DIR / "pyproject.toml"
APP_PY = BASE_DIR / "portprotonqt" / "app.py"
GITEA_WORKFLOW = BASE_DIR / ".gitea" / "workflows" / "build.yml"
CHANGELOG = BASE_DIR / "CHANGELOG.md"
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')
return bool(count)
def bump_arch(path: Path, old: str, new: str) -> bool:
"""
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')
return bool(count)
def bump_fedora(path: Path, old: str, new: str) -> bool:
"""
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')
return bool(count)
def bump_pyproject(path: Path, old: str, new: str) -> bool:
"""
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')
return bool(count)
def bump_app_py(path: Path, old: str, new: str) -> bool:
"""
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')
return bool(count)
def bump_workflow(path: Path, old: str, new: str) -> bool:
"""
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')
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():
parser = argparse.ArgumentParser(description='Bump project version in specific files')
@@ -111,7 +121,8 @@ def main():
(FEDORA_SPEC, bump_fedora),
(PYPROJECT, bump_pyproject),
(APP_PY, bump_app_py),
(GITEA_WORKFLOW, bump_workflow)
(GITEA_WORKFLOW, bump_workflow),
(CHANGELOG, bump_changelog)
]
updated = []
@@ -126,6 +137,5 @@ def main():
else:
print(f"No occurrences of version {old} found in specified files.")
if __name__ == '__main__':
main()

View File

@@ -40,7 +40,7 @@ class ContextMenuManager:
"""
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_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
if os.path.exists(desktop_path):

View File

@@ -133,7 +133,7 @@ class FlowLayout(QLayout):
class ClickableLabel(QLabel):
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=...) первый аргумент строка,
@@ -143,6 +143,7 @@ class ClickableLabel(QLabel):
icon: QIcon или None иконка, которая будет отрисована вместе с текстом.
icon_size: int размер иконки (ширина и высота).
icon_space: int отступ между иконкой и текстом.
change_cursor: bool изменять ли курсор на PointingHandCursor при наведении (по умолчанию True).
"""
if args and isinstance(args[0], str):
text = args[0]
@@ -161,7 +162,8 @@ class ClickableLabel(QLabel):
self._icon = icon
self._icon_size = icon_size
self._icon_space = icon_space
self.setCursor(Qt.CursorShape.PointingHandCursor)
if change_cursor:
self.setCursor(Qt.CursorShape.PointingHandCursor)
def setIcon(self, icon):
"""Устанавливает иконку и перерисовывает виджет."""

View File

@@ -1,4 +1,6 @@
import re
import requests
import requests.exceptions
import threading
import orjson
from pathlib import Path
@@ -27,20 +29,20 @@ def get_cache_dir() -> Path:
def get_egs_game_description_async(
app_name: str,
callback: Callable[[str], None],
namespace: str | None = None,
cache_ttl: int = 3600
) -> None:
"""
Asynchronously fetches the game description from the Epic Games Store API.
Uses per-app cache files named egs_app_{app_name}.json in ~/.cache/PortProtonQT.
Checks the cache first; if the description is cached and not expired, returns it.
Prioritizes the page with type 'productHome' for the base game description.
Prioritizes GraphQL API with namespace for slug and description.
Falls back to legacy API if GraphQL provides a slug but no description.
Caches results in ~/.cache/PortProtonQT/egs_app_{app_name}.json.
Handles DNS resolution failures gracefully.
"""
cache_dir = get_cache_dir()
cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json"
# Initialize content to avoid unbound variable
content = b""
# Load existing cache
# Check cache
if cache_file.exists():
try:
with open(cache_file, "rb") as f:
@@ -70,10 +72,6 @@ def get_egs_game_description_async(
app_name,
str(e)
)
logger.debug(
"Cache file content (first 100 chars): %s",
content[:100].decode('utf-8', errors='replace')
)
cache_file.unlink(missing_ok=True)
except Exception as e:
logger.error(
@@ -84,88 +82,205 @@ def get_egs_game_description_async(
cache_file.unlink(missing_ok=True)
lang = get_egs_language()
slug = app_name.lower().replace(":", "").replace(" ", "-")
url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{slug}"
headers = {
"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:
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()
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):
logger.warning("Invalid JSON structure for %s: %s", app_name, type(data))
callback("")
return
description = ""
logger.warning("Invalid JSON structure for %s in legacy API: %s", app_name, type(data))
return ""
pages = data.get("pages", [])
if pages:
# Look for the page with type "productHome" for the base game
for page in pages:
if page.get("type") == "productHome":
about_data = page.get("data", {}).get("about", {})
description = about_data.get("shortDescription", "")
break
else:
# Fallback to first page's description if no productHome is found
description = (
pages[0].get("data", {})
.get("about", {})
.get("shortDescription", "")
)
if not description:
logger.warning("No valid description found for %s", app_name)
logger.debug(
"Fetched EGS description for %s: %s",
app_name,
(description[:100] + "...") if len(description) > 100 else description
)
cache_entry = {"description": description, "timestamp": time.time()}
try:
temp_file = cache_file.with_suffix('.tmp')
with open(temp_file, "wb") as f:
f.write(orjson.dumps(cache_entry))
temp_file.replace(cache_file)
logger.debug(
"Saved description to cache for %s", app_name
)
except Exception as e:
logger.error(
"Failed to save description cache for %s: %s",
app_name,
str(e)
)
callback(description)
return page.get("data", {}).get("about", {}).get("shortDescription", "")
return pages[0].get("data", {}).get("about", {}).get("shortDescription", "")
return ""
except requests.HTTPError as e:
if e.response.status_code == 404:
logger.info("Legacy API returned 404 for %s", app_name)
else:
logger.warning("HTTP error in legacy API for %s: %s", app_name, str(e))
return ""
except requests.exceptions.ConnectionError as e:
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
return ""
except requests.RequestException as e:
logger.warning(
"Failed to fetch EGS description for %s: %s",
app_name,
str(e)
)
callback("")
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", app_name
)
callback("")
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:
logger.warning("No valid description found for %s", app_name)
# Save to cache
cache_entry = {"description": description, "timestamp": time.time()}
try:
temp_file = cache_file.with_suffix('.tmp')
with open(temp_file, "wb") as f:
f.write(orjson.dumps(cache_entry))
temp_file.replace(cache_file)
logger.debug("Saved description to cache for %s", app_name)
except Exception as e:
logger.error(
"Unexpected error fetching EGS description for %s: %s",
app_name,
str(e)
)
callback("")
logger.error("Failed to save description cache for %s: %s", app_name, str(e))
thread = threading.Thread(
target=fetch_description,
daemon=True
)
callback(description)
thread = threading.Thread(target=fetch_description, daemon=True)
thread.start()
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
"""
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.

View File

@@ -5,7 +5,7 @@ from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel
@@ -27,7 +27,7 @@ class GameCard(QFrame):
openGameFolderRequested = Signal(str, str) # name, exec_line
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, steam_game,
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):
super().__init__(parent)
self.name = name
@@ -40,7 +40,7 @@ class GameCard(QFrame):
self.formatted_playtime = formatted_playtime
self.protondb_tier = protondb_tier
self.anticheat_status = anticheat_status
self.steam_game = steam_game
self.game_source = game_source
self.last_launch_ts = last_launch_ts
self.playtime_seconds = playtime_seconds
@@ -51,6 +51,7 @@ class GameCard(QFrame):
self.theme_manager = ThemeManager()
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()
# Дополнительное пространство для анимации
@@ -105,7 +106,6 @@ class GameCard(QFrame):
def on_cover_loaded(pixmap):
label = label_ref()
if label is None:
# QLabel уже удалён — ничего не делаем
return
label.setPixmap(round_corners(pixmap, 15))
@@ -121,6 +121,10 @@ class GameCard(QFrame):
self.update_favorite_icon()
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 бейдж
tier_text = self.getProtonDBText(protondb_tier)
if tier_text:
@@ -134,11 +138,11 @@ class GameCard(QFrame):
icon_space=3,
)
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
else:
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)
protondb_visible = False
@@ -152,10 +156,37 @@ class GameCard(QFrame):
icon_space=5,
)
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.steamLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
steam_visible = (str(steam_game).lower() == "true")
self.steamLabel.setFixedWidth(int(card_width * 2/3))
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 бейдж
anticheat_text = self.getAntiCheatText(anticheat_status)
if anticheat_text:
@@ -169,11 +200,11 @@ class GameCard(QFrame):
icon_space=3,
)
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
else:
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)
anticheat_visible = False
@@ -182,11 +213,21 @@ class GameCard(QFrame):
badge_spacing = 5
top_y = 10
badge_y_positions = []
badge_width = int(card_width * 2/3) # Фиксированная ширина бейджей
badge_width = int(card_width * 2/3)
if steam_visible:
steam_x = card_width - badge_width - right_margin
self.steamLabel.move(steam_x, top_y)
badge_y_positions.append(top_y + self.steamLabel.height())
if 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:
protondb_x = card_width - badge_width - right_margin
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.protondbLabel.raise_()
self.portprotonLabel.raise_()
self.egsLabel.raise_()
self.steamLabel.raise_()
self.protondbLabel.clicked.connect(self.open_protondb_report)
self.steamLabel.clicked.connect(self.open_steam_page)
@@ -212,24 +255,73 @@ class GameCard(QFrame):
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
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):
"""Delegate context menu display to ContextMenuManager."""
if self.context_menu_manager:
self.context_menu_manager.show_context_menu(self, pos)
def getAntiCheatText(self, status):
if not status:
return ""
translations = {
"supported": _("Supported"),
"running": _("Running"),
"planned": _("Planned"),
"broken": _("Broken"),
"denied": _("Denied")
}
return translations.get(status.lower(), "")
@staticmethod
def getAntiCheatText(status: str) -> str:
if not status:
return ""
translations = {
"supported": _("Supported"),
"running": _("Running"),
"planned": _("Planned"),
"broken": _("Broken"),
"denied": _("Denied")
}
return translations.get(status.lower(), "")
def getAntiCheatIconFilename(self, status):
@staticmethod
def getAntiCheatIconFilename(status: str) -> str:
status = status.lower()
if status in ("supported", "running"):
return "platinum-gold"
@@ -237,7 +329,8 @@ class GameCard(QFrame):
return "broken"
return ""
def getProtonDBText(self, tier):
@staticmethod
def getProtonDBText(tier: str) -> str:
if not tier:
return ""
translations = {
@@ -250,7 +343,8 @@ class GameCard(QFrame):
}
return translations.get(tier.lower(), "")
def getProtonDBIconFilename(self, tier):
@staticmethod
def getProtonDBIconFilename(tier: str) -> str:
tier = tier.lower()
if tier in ("platinum", "gold"):
return "platinum-gold"
@@ -269,9 +363,9 @@ class GameCard(QFrame):
QDesktopServices.openUrl(url)
def open_weanticheatyet_page(self):
formatted_name = self.name.lower().replace(" ", "-")
url = QUrl(f"https://areweanticheatyet.com/game/{formatted_name}")
QDesktopServices.openUrl(url)
formatted_name = self.name.lower().replace(" ", "-")
url = QUrl(f"https://areweanticheatyet.com/game/{formatted_name}")
QDesktopServices.openUrl(url)
def update_favorite_icon(self):
if self.is_favorite:
@@ -451,7 +545,8 @@ class GameCard(QFrame):
self.last_launch,
self.formatted_playtime,
self.protondb_tier,
self.steam_game
self.game_source,
self.anticheat_status
)
super().mousePressEvent(event)
@@ -467,7 +562,8 @@ class GameCard(QFrame):
self.last_launch,
self.formatted_playtime,
self.protondb_tier,
self.steam_game
self.game_source,
self.anticheat_status
)
else:
super().keyPressEvent(event)

View File

@@ -4,13 +4,13 @@ from typing import Protocol, cast
from evdev import InputDevice, ecodes, list_devices
import pyudev
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit
from PySide6.QtCore import Qt, QObject, QEvent, QPoint
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot
from PySide6.QtGui import QKeyEvent
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel
from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry
logger = get_logger(__name__)
@@ -31,23 +31,15 @@ class MainWindowProtocol(Protocol):
currentDetailPage: QWidget | None
current_exec_line: str | None
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers (https://www.kernel.org/doc/html/v4.12/input/gamepad.html)
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers
BUTTONS = {
# South button: X (PlayStation), A (Xbox), B (Switch Joy-Con south)
'confirm': {ecodes.BTN_SOUTH, ecodes.BTN_A},
# East button: Circle (PS), B (Xbox), A (Switch Joy-Con east)
'back': {ecodes.BTN_EAST, ecodes.BTN_B},
# North button: Triangle (PS), Y (Xbox), X (Switch Joy-Con north)
'add_game': {ecodes.BTN_NORTH, ecodes.BTN_Y},
# Shoulder buttons: L1/L2 (PS), LB (Xbox), L (Switch): BTN_TL, BTN_TL2
'confirm': {ecodes.BTN_A},
'back': {ecodes.BTN_B},
'add_game': {ecodes.BTN_Y},
'prev_tab': {ecodes.BTN_TL, ecodes.BTN_TL2},
# Shoulder buttons: R1/R2 (PS), RB (Xbox), R (Switch): BTN_TR, BTN_TR2
'next_tab': {ecodes.BTN_TR, ecodes.BTN_TR2},
# Optional: stick presses on Switch Joy-Con
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
# Start button for context menu
'context_menu': {ecodes.BTN_START},
# Select/home for back/menu
'menu': {ecodes.BTN_SELECT, ecodes.BTN_MODE},
}
@@ -55,8 +47,14 @@ class InputManager(QObject):
"""
Manages input from gamepads and keyboards for navigating the application interface.
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
for seamless UI interaction.
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__(
self,
main_window: MainWindowProtocol,
@@ -81,22 +79,48 @@ class InputManager(QObject):
self.running = True
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
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
else:
logger.error("QApplication instance is None, cannot install event filter")
# Initialize evdev + hotplug
self.init_gamepad()
@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:
app = QApplication.instance()
if not app:
return super().eventFilter(obj, event)
# 1) Интересуют только нажатия клавиш
# Handle only key press events
if not (isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress):
return super().eventFilter(obj, event)
@@ -105,17 +129,16 @@ class InputManager(QObject):
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
# 2) Закрытие приложения по Ctrl+Q
# Close application with Ctrl+Q
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
app.quit()
return True
# 3) Если открыт любой popup — не перехватываем ENTER, ESC и стрелки
# Skip navigation keys if a popup is open
if popup:
# возвращаем False, чтобы событие пошло дальше в Qt и закрыло popup как нужно
return False
# 4) Навигация в полноэкранном просмотре
# FullscreenDialog navigation
active_win = QApplication.activeWindow()
if isinstance(active_win, FullscreenDialog):
if key == Qt.Key.Key_Right:
@@ -128,27 +151,25 @@ class InputManager(QObject):
active_win.close()
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.current_exec_line:
self._parent.toggleGame(self._parent.current_exec_line, None)
return True
# 6) Открытие контекстного меню для GameCard
# Context menu for GameCard
if isinstance(focused, GameCard):
if key == Qt.Key.Key_F10 and Qt.KeyboardModifier.ShiftModifier:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return True
# 7) Навигация по карточкам в Library
# Navigation in Library tab
if self._parent.stackedWidget.currentIndex() == 0:
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
scroll_area = self._parent.gamesListWidget.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if not scroll_area:
logger.warning("No QScrollArea found for gamesListWidget")
if isinstance(focused, GameCard):
current_index = game_cards.index(focused) if focused in game_cards else -1
@@ -184,7 +205,7 @@ class InputManager(QObject):
scroll_area.ensureWidgetVisible(next_card, 50, 50)
return True
# 8) Переключение вкладок ←/→
# Tab switching with Left/Right keys
idx = self._parent.stackedWidget.currentIndex()
total = len(self._parent.tabButtons)
if key == Qt.Key.Key_Left and not isinstance(focused, GameCard):
@@ -198,7 +219,7 @@ class InputManager(QObject):
self._parent.tabButtons[new].setFocus()
return True
# 9) Спуск в содержимое вкладки ↓
# Navigate down into tab content
if key == Qt.Key.Key_Down:
if isinstance(focused, NavLabel):
page = self._parent.stackedWidget.currentWidget()
@@ -212,15 +233,15 @@ class InputManager(QObject):
focused.focusNextChild()
return True
# 10) Подъём по содержимому вкладки ↑
# Navigate up through tab content
if key == Qt.Key.Key_Up:
if isinstance(focused, NavLabel):
return True # Не даём уйти выше NavLabel
return True
if focused is not None:
focused.focusPreviousChild()
return True
# 11) Общие: Activate, Back, Add
# General actions: Activate, Back, Add
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self._parent.activateFocusedWidget()
return True
@@ -235,18 +256,11 @@ class InputManager(QObject):
self._parent.openAddGameDialog()
return True
# 12) Переключение полноэкранного режима по F11
# Toggle fullscreen with F11
if key == Qt.Key.Key_F11:
if read_fullscreen_config():
return True
window = self._parent
if isinstance(window, QWidget):
if self._is_fullscreen:
window.showNormal()
self._is_fullscreen = False
else:
window.showFullScreen()
self._is_fullscreen = True
self.toggle_fullscreen.emit(not self._is_fullscreen)
return True
return super().eventFilter(obj, event)
@@ -254,45 +268,62 @@ class InputManager(QObject):
def init_gamepad(self) -> None:
self.check_gamepad()
threading.Thread(target=self.run_udev_monitor, daemon=True).start()
logger.info("Input support initialized with hotplug (evdev + pyudev)")
logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
def run_udev_monitor(self) -> None:
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem='input')
observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
observer.start()
while self.running:
time.sleep(1)
try:
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem='input')
observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
observer.start()
while self.running:
time.sleep(1)
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:
if action == 'add':
time.sleep(0.1)
self.check_gamepad()
elif action == 'remove' and self.gamepad:
if not any(self.gamepad.path == path for path in list_devices()):
logger.info("Gamepad disconnected")
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join()
try:
if action == 'add':
time.sleep(0.1)
self.check_gamepad()
elif action == 'remove' and self.gamepad:
if not any(self.gamepad.path == path for path in list_devices()):
logger.info("Gamepad disconnected")
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join()
# 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:
new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}")
self.gamepad = new_gamepad
if self.gamepad_thread:
self.gamepad_thread.join()
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
self.gamepad_thread.start()
try:
new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}")
self.gamepad = new_gamepad
if self.gamepad_thread:
self.gamepad_thread.join()
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
self.gamepad_thread.start()
# 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:
devices = [InputDevice(path) for path in list_devices()]
for device in devices:
caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device
return None
try:
devices = [InputDevice(path) for path in list_devices()]
for device in devices:
caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device
return None
except Exception as e:
logger.error(f"Error finding gamepad: {e}", exc_info=True)
return None
def monitor_gamepad(self) -> None:
try:
@@ -305,126 +336,148 @@ class InputManager(QObject):
continue
now = time.time()
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:
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:
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:
app = QApplication.instance()
if app is None:
logger.error("QApplication instance is None")
return
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
# FullscreenDialog
if isinstance(active, FullscreenDialog):
if button_code in BUTTONS['prev_tab']:
active.show_prev()
elif button_code in BUTTONS['next_tab']:
active.show_next()
elif button_code in BUTTONS['back']:
active.close()
return
# Context menu for GameCard
if isinstance(focused, GameCard):
if button_code in BUTTONS['context_menu']:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return
# Game launch on detail page
if (button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']) and self._parent.currentDetailPage is not None:
if self._parent.current_exec_line:
self._parent.toggleGame(self._parent.current_exec_line, None)
return
# Standard navigation
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
self._parent.activateFocusedWidget()
elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
elif button_code in BUTTONS['add_game']:
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']:
idx = (self._parent.stackedWidget.currentIndex() - 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
elif button_code in BUTTONS['next_tab']:
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
def handle_dpad(self, code: int, value: int, current_time: float) -> None:
app = QApplication.instance()
if app is None:
logger.error("QApplication instance is None")
return
active = QApplication.activeWindow()
# Fullscreen horizontal
if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X:
if value < 0:
active.show_prev()
elif value > 0:
active.show_next()
return
# Vertical navigation (DPAD up/down)
if code == ecodes.ABS_HAT0Y:
# ignore release
if value == 0:
@Slot(int)
def handle_button_slot(self, button_code: int) -> None:
try:
app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
if value > 0:
# down
if isinstance(focused, NavLabel):
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus()
return
elif focused:
focused.focusNextChild()
return
elif value < 0 and focused:
# up
focused.focusPreviousChild()
# FullscreenDialog
if isinstance(active, FullscreenDialog):
if button_code in BUTTONS['prev_tab']:
active.show_prev()
elif button_code in BUTTONS['next_tab']:
active.show_next()
elif button_code in BUTTONS['back']:
active.close()
return
# Horizontal wrap navigation repeat logic
if code != ecodes.ABS_HAT0X:
return
if value == 0:
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
return
if not self.axis_moving:
self.trigger_dpad_movement(code, value)
self.last_move_time = current_time
self.axis_moving = True
elif current_time - self.last_move_time >= self.current_axis_delay:
self.trigger_dpad_movement(code, value)
self.last_move_time = current_time
self.current_axis_delay = self.repeat_axis_move_delay
# Context menu for GameCard
if isinstance(focused, GameCard):
if button_code in BUTTONS['context_menu']:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return
# Game launch on detail page
if (button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']) and self._parent.currentDetailPage is not None:
if self._parent.current_exec_line:
self._parent.toggleGame(self._parent.current_exec_line, None)
return
# Standard navigation
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
self._parent.activateFocusedWidget()
elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
elif button_code in BUTTONS['add_game']:
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']:
idx = (self._parent.stackedWidget.currentIndex() - 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
elif button_code in BUTTONS['next_tab']:
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
@Slot(int, int, float)
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
try:
app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow()
# Fullscreen horizontal
if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X:
if value < 0:
active.show_prev()
elif value > 0:
active.show_next()
return
# Vertical navigation (DPAD up/down)
if code == ecodes.ABS_HAT0Y:
if value == 0:
return
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
if value > 0:
if isinstance(focused, NavLabel):
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus()
return
elif focused:
focused.focusNextChild()
return
elif value < 0 and focused:
focused.focusPreviousChild()
return
# Horizontal wrap navigation repeat logic
if code != ecodes.ABS_HAT0X:
return
if value == 0:
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
return
if not self.axis_moving:
self.trigger_dpad_movement(code, value)
self.last_move_time = current_time
self.axis_moving = True
elif current_time - self.last_move_time >= self.current_axis_delay:
self.trigger_dpad_movement(code, value)
self.last_move_time = current_time
self.current_axis_delay = self.repeat_axis_move_delay
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:
if code != ecodes.ABS_HAT0X:
return
idx = self._parent.stackedWidget.currentIndex()
if value < 0:
new = (idx - 1) % len(self._parent.tabButtons)
else:
new = (idx + 1) % len(self._parent.tabButtons)
self._parent.switchTab(new)
self._parent.tabButtons[new].setFocus(Qt.FocusReason.OtherFocusReason)
try:
if code != ecodes.ABS_HAT0X:
return
idx = self._parent.stackedWidget.currentIndex()
if value < 0:
new = (idx - 1) % len(self._parent.tabButtons)
else:
new = (idx + 1) % len(self._parent.tabButtons)
self._parent.switchTab(new)
self._parent.tabButtons[new].setFocus(Qt.FocusReason.OtherFocusReason)
except Exception as e:
logger.error(f"Error in trigger_dpad_movement: {e}", exc_info=True)
def cleanup(self) -> None:
self.running = False
if self.gamepad:
self.gamepad.close()
logger.info("Input support cleaned up")
try:
self.running = False
if self.gamepad_thread:
self.gamepad_thread.join()
if self.gamepad:
self.gamepad.close()
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_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()
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQT/legendary
self.legendary_config_path = os.path.join(
@@ -308,7 +314,7 @@ class MainWindow(QMainWindow):
'controller_support': '',
'protondb_tier': '',
'name': name,
'steam_game': 'true'
'game_source': 'steam'
}
last_launch = format_last_launch(datetime.fromtimestamp(last_played)) if last_played else _("Never")
steam_games.append((
@@ -324,7 +330,7 @@ class MainWindow(QMainWindow):
info.get("anticheat_status", ""),
last_played,
playtime_seconds,
"true"
"steam"
))
processed_count += 1
self.pending_games.append(None)
@@ -456,7 +462,6 @@ class MainWindow(QMainWindow):
final_cover = (user_cover if user_cover else
builtin_cover if builtin_cover else
steam_info.get("cover", "") or entry.get("Icon", ""))
steam_game = "false"
callback((
final_name,
final_desc,
@@ -470,7 +475,7 @@ class MainWindow(QMainWindow):
steam_info.get("anticheat_status", ""),
get_last_launch_timestamp(exe_name) if exe_name else 0,
playtime_seconds,
steam_game
"portproton"
))
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)
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):
"""
Сохраняет параметры конфигурации в конфигурационный файл,
Сохраняет параметры конфигурации в конфигурационный файл.
"""
time_idx = self.timeDetailCombo.currentIndex()
time_key = self.time_keys[time_idx]
@@ -1127,17 +1141,33 @@ class MainWindow(QMainWindow):
fullscreen = self.fullscreenCheckBox.isChecked()
save_fullscreen_config(fullscreen)
# Перезагружаем настройки
read_time_config()
self.games = self.loadGames()
self.updateGameGrid()
for card in self.game_card_cache.values():
card.update_badge_visibility(filter_key)
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()
if fullscreen:
self.showFullScreen()
else:
self.showNormal()
save_window_geometry(self.width(), self.height())
if self.isFullScreen():
# Переходим в нормальный режим и восстанавливаем сохраненные размеры
width, height = read_window_geometry()
self.showNormal()
if width > 0 and height > 0:
self.resize(width, height)
# Сохраняем геометрию только если окно не в полноэкранном режиме
if not self.isFullScreen():
save_window_geometry(self.width(), self.height())
self.statusBar().showMessage(_("Settings saved"), 3000)
@@ -1302,7 +1332,7 @@ class MainWindow(QMainWindow):
def darkenColor(self, color, factor=200):
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()
self._animations = {}
imageLabel = QLabel()
@@ -1357,7 +1387,7 @@ class MainWindow(QMainWindow):
coverLayout.addWidget(imageLabel)
# Добавляем значок избранного поверх обложки в левом верхнем углу
# Значок избранного
favoriteLabelCover = ClickableLabel(coverFrame)
favoriteLabelCover.setFixedSize(*self.theme.favoriteLabelSize)
favoriteLabelCover.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
@@ -1370,6 +1400,139 @@ class MainWindow(QMainWindow):
favoriteLabelCover.move(8, 8)
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)
# Детали игры (справа)
@@ -1515,7 +1678,7 @@ class MainWindow(QMainWindow):
focused_widget.last_launch,
focused_widget.formatted_playtime,
focused_widget.protondb_tier,
focused_widget.steam_game
focused_widget.game_source
)
def goBackDetailPage(self, page: QWidget | None) -> None: