Compare commits
41 Commits
a6ea998535
...
7185019a3f
Author | SHA1 | Date | |
---|---|---|---|
7185019a3f
|
|||
1f4f4093bd
|
|||
2d72fdb4c7
|
|||
d9729ebbea
|
|||
43e7d5b65b
|
|||
70dca2b704
|
|||
2875efb050
|
|||
ce097e489b
|
|||
f8de5ec589
|
|||
fea07e19fe
|
|||
37b108f689
|
|||
78f5118709 | |||
1f14dd7fdf | |||
3d3bdd8f98 | |||
9d7c674544 | |||
e6c90508ab | |||
d0eea92139
|
|||
04726491c0
|
|||
bd1b7c07ae
|
|||
e6161d2e3f
|
|||
b82080600f
|
|||
05693514aa
|
|||
1c2835a933
|
|||
d229914fb6
|
|||
ce69a18249
|
|||
4d58830910 | |||
016ba537be
|
|||
6eeb93f6ba
|
|||
3f5d058740
|
|||
1a9228b76d
|
|||
e9e0bea854
|
|||
f7d9f5c150
|
|||
bcb5987d31
|
|||
b1aa987e4e
|
|||
f4c8b70bd0
|
|||
ff960df77c
|
|||
a57f509295
|
|||
32bbe89911
|
|||
593db00166
|
|||
79a78c785b
|
|||
0b92d058a9
|
18
CHANGELOG.md
18
CHANGELOG.md
@ -6,12 +6,21 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Аргумент `--session` для запуска приложения в gamescope с GAMESCOPE_CMD
|
||||
|
||||
### Changed
|
||||
- Удалены сборки для Fedora 40
|
||||
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
|
||||
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
|
||||
|
||||
### Fixed
|
||||
- Дублирование обводки выделения карточек при быстром перемешении мыши
|
||||
- Завершение приложения при закритие окна
|
||||
- Использование системной палитры в темах
|
||||
- Ошибки темы в нативном пакете
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
|
||||
---
|
||||
|
||||
@ -76,6 +85,10 @@
|
||||
- Корректная обработка событий геймпада во время игры
|
||||
- Убийсво всех процессов "зомби" при закрытии программы
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
- @Dervart
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] – 2025-05-17
|
||||
@ -96,6 +109,11 @@
|
||||
- Зависание GUI
|
||||
- Сбой при повреждённом Steam
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
- @Dervart
|
||||
- @alex2844
|
||||
|
||||
---
|
||||
|
||||
> См. подробности по каждому коммиту в истории репозитория.
|
||||
|
10
README.md
10
README.md
@ -4,7 +4,6 @@
|
||||
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
|
||||
</div>
|
||||
|
||||
|
||||
## В планах
|
||||
|
||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
||||
@ -15,7 +14,8 @@
|
||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
||||
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
|
||||
- [ ] Продумать систему вкладок вместо текущей
|
||||
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||
- [ ] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||
- [ ] Разобраться почему теряется часть стилей в Gamescope
|
||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
|
||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
||||
- [X] Получать описания и названия игр из базы данных Steam
|
||||
@ -41,7 +41,10 @@
|
||||
- [X] Добавить парсинг ярлыков из Steam
|
||||
- [X] Добавить парсинг ярлыков из EGS (скрыто для переработки)
|
||||
- [ ] Избавиться от бинарника legendary
|
||||
- [ ] Добавить запуск и скачивание игр из EGS
|
||||
- [X] Добавить запуск игр из EGS
|
||||
- [ ] Добавить скачивание игр из EGS
|
||||
- [ ] Добавить поддержку запуска сторонних игр из EGS
|
||||
- [ ] Добавить поддержку запуска игр с EOS
|
||||
- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода
|
||||
- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api)
|
||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
||||
@ -68,6 +71,7 @@
|
||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||
- [ ] Доделать светлую тему
|
||||
- [ ] Добавить подсказки к управлению с геймпада
|
||||
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
|
8
config.js
Normal file
8
config.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
"endpoint": "https://git.linux-gaming.ru/api/v1",
|
||||
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
|
||||
"platform": "gitea",
|
||||
"onboardingConfigFileName": "renovate.json",
|
||||
"autodiscover": true,
|
||||
"optimizeForDisabled": true,
|
||||
};
|
@ -1,4 +1,6 @@
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
@ -33,6 +35,13 @@ def main():
|
||||
|
||||
window = MainWindow()
|
||||
|
||||
if args.session:
|
||||
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
|
||||
cmd = f"{gamescope_cmd} -- portprotonqt"
|
||||
logger.info(f"Executing: {cmd}")
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
sys.exit(0)
|
||||
|
||||
if args.fullscreen:
|
||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||
save_fullscreen_config(True)
|
||||
|
@ -13,4 +13,9 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session",
|
||||
action="store_true",
|
||||
help="Запустить приложение с использованием gamescope"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
@ -3,13 +3,26 @@ import shlex
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import logging
|
||||
import re
|
||||
import json
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog
|
||||
from PySide6.QtCore import QUrl, QPoint
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable
|
||||
import vdf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ContextMenuSignals(QObject):
|
||||
"""Signals for thread-safe UI updates from worker threads."""
|
||||
show_status_message = Signal(str, int)
|
||||
show_warning_dialog = Signal(str, str)
|
||||
|
||||
class ContextMenuManager:
|
||||
"""Manages context menu actions for game management in PortProtonQt."""
|
||||
@ -30,6 +43,56 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.load_games = load_games_callback
|
||||
self.update_game_grid = update_game_grid_callback
|
||||
self.legendary_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQt", "legendary_cache", "legendary"
|
||||
)
|
||||
self.legendary_config_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQt", "legendary_cache"
|
||||
)
|
||||
# Initialize signals for thread-safe UI updates
|
||||
self.signals = ContextMenuSignals()
|
||||
if self.parent.statusBar() is None:
|
||||
logger.warning("Status bar is not initialized in MainWindow")
|
||||
else:
|
||||
self.signals.show_status_message.connect(
|
||||
self.parent.statusBar().showMessage,
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
logger.debug("Connected show_status_message signal to statusBar")
|
||||
self.signals.show_warning_dialog.connect(
|
||||
self._show_warning_dialog,
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
|
||||
def _show_warning_dialog(self, title: str, message: str):
|
||||
"""Show a warning dialog in the main thread."""
|
||||
logger.debug("Showing warning dialog: %s - %s", title, message)
|
||||
QMessageBox.warning(self.parent, title, message)
|
||||
|
||||
def _is_egs_game_installed(self, app_name: str) -> bool:
|
||||
"""
|
||||
Check if an EGS game is installed by reading installed.json.
|
||||
|
||||
Args:
|
||||
app_name: The Legendary app_name (unique identifier for the game).
|
||||
|
||||
Returns:
|
||||
bool: True if the game is installed, False otherwise.
|
||||
"""
|
||||
installed_json_path = os.path.join(self.legendary_config_path, "installed.json")
|
||||
if not os.path.exists(installed_json_path):
|
||||
logger.debug("installed.json not found at %s", installed_json_path)
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(installed_json_path, encoding="utf-8") as f:
|
||||
installed_games = json.load(f)
|
||||
return app_name in installed_games
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error("Failed to read installed.json: %s", e)
|
||||
return False
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
@ -39,7 +102,6 @@ class ContextMenuManager:
|
||||
game_card: The GameCard instance requesting the context menu.
|
||||
pos: The position (in widget coordinates) where the menu should appear.
|
||||
"""
|
||||
|
||||
menu = QMenu(self.parent)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
@ -54,9 +116,31 @@ class ContextMenuManager:
|
||||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
|
||||
|
||||
if game_card.game_source == "epic":
|
||||
import_action = menu.addAction(_("Import to Legendary"))
|
||||
import_action.triggered.connect(
|
||||
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
||||
# Always show Import to Legendary
|
||||
import_action = menu.addAction(_("Import to Legendary"))
|
||||
import_action.triggered.connect(
|
||||
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
||||
)
|
||||
# Show other actions only if the game is installed
|
||||
if self._is_egs_game_installed(game_card.appid):
|
||||
uninstall_action = menu.addAction(_("Uninstall Game"))
|
||||
uninstall_action.triggered.connect(
|
||||
lambda: self.uninstall_egs_game(game_card.name, game_card.appid)
|
||||
)
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
if is_in_steam:
|
||||
remove_steam_action = menu.addAction(_("Remove from Steam"))
|
||||
remove_steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
)
|
||||
else:
|
||||
add_steam_action = menu.addAction(_("Add to Steam"))
|
||||
add_steam_action.triggered.connect(
|
||||
lambda: self.add_egs_to_steam(game_card.name, game_card.appid)
|
||||
)
|
||||
open_folder_action = menu.addAction(_("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(
|
||||
lambda: self.open_egs_game_folder(game_card.appid)
|
||||
)
|
||||
|
||||
if game_card.game_source not in ("steam", "epic"):
|
||||
@ -70,13 +154,17 @@ class ContextMenuManager:
|
||||
add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line))
|
||||
|
||||
edit_action = menu.addAction(_("Edit Shortcut"))
|
||||
edit_action.triggered.connect(lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
delete_action = menu.addAction(_("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
|
||||
open_folder_action = menu.addAction(_("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(lambda: self.open_game_folder(game_card.name, game_card.exec_line))
|
||||
open_folder_action.triggered.connect(
|
||||
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
|
||||
)
|
||||
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||
@ -87,20 +175,88 @@ class ContextMenuManager:
|
||||
add_action = menu.addAction(_("Add to Menu"))
|
||||
add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line))
|
||||
|
||||
# Add Steam-related actions
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
if is_in_steam:
|
||||
remove_steam_action = menu.addAction(_("Remove from Steam"))
|
||||
remove_steam_action.triggered.connect(lambda: self.remove_from_steam(game_card.name, game_card.exec_line))
|
||||
remove_steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
)
|
||||
else:
|
||||
add_steam_action = menu.addAction(_("Add to Steam"))
|
||||
add_steam_action.triggered.connect(lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path))
|
||||
add_steam_action.triggered.connect(
|
||||
lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
|
||||
def import_to_legendary(self, game_name, app_name):
|
||||
def add_egs_to_steam(self, game_name: str, app_name: str):
|
||||
"""
|
||||
Imports an installed Epic Games Store game to Legendary using the provided app_name.
|
||||
Adds an EGS game to Steam using the egs_api.
|
||||
|
||||
Args:
|
||||
game_name: The display name of the game.
|
||||
app_name: The Legendary app_name (unique identifier for the game).
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
if not os.path.exists(self.legendary_path):
|
||||
self._show_warning_dialog(_("Error"), _("Legendary executable not found at {0}").format(self.legendary_path))
|
||||
return
|
||||
|
||||
def on_add_to_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
self.signals.show_status_message.emit(
|
||||
_("The game was added successfully. Please restart Steam for changes to take effect."), 5000
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(_("Adding '{0}' to Steam...").format(game_name), 0)
|
||||
logger.debug("Direct status message: Adding '%s' to Steam", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when adding '%s' to Steam", game_name)
|
||||
add_egs_to_steam(app_name, game_name, self.legendary_path, on_add_to_steam_result)
|
||||
|
||||
def open_egs_game_folder(self, app_name: str):
|
||||
"""
|
||||
Opens the folder containing the EGS game's executable.
|
||||
|
||||
Args:
|
||||
app_name: The Legendary app_name (unique identifier for the game).
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
exe_path = get_egs_executable(app_name, self.legendary_config_path)
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Executable file not found for game: {0}").format(app_name)
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Opened folder for EGS game '{0}'").format(app_name), 3000
|
||||
)
|
||||
logger.debug("Direct status message: Opened folder for '%s'", app_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when opening folder for '%s'", app_name)
|
||||
except Exception as e:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to open game folder: {0}").format(str(e))
|
||||
)
|
||||
|
||||
def import_to_legendary(self, game_name, app_name):
|
||||
"""
|
||||
Imports an installed Epic Games Store game to Legendary asynchronously.
|
||||
|
||||
Args:
|
||||
game_name: The display name of the game.
|
||||
@ -109,63 +265,118 @@ class ContextMenuManager:
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
# Открываем диалог для выбора папки с установленной игрой
|
||||
folder_path = QFileDialog.getExistingDirectory(
|
||||
self.parent,
|
||||
_("Select Game Installation Folder"),
|
||||
os.path.expanduser("~")
|
||||
)
|
||||
if not folder_path:
|
||||
self.parent.statusBar().showMessage(_("No folder selected"), 3000)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(_("No folder selected"), 3000)
|
||||
logger.debug("Direct status message: No folder selected for '%s'", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when no folder selected for '%s'", game_name)
|
||||
return
|
||||
|
||||
# Путь к legendary
|
||||
legendary_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQt", "legendary_cache", "legendary"
|
||||
)
|
||||
if not os.path.exists(legendary_path):
|
||||
QMessageBox.warning(
|
||||
self.parent,
|
||||
if not os.path.exists(self.legendary_path):
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Legendary executable not found at {0}").format(legendary_path)
|
||||
_("Legendary executable not found at {0}").format(self.legendary_path)
|
||||
)
|
||||
return
|
||||
|
||||
# Формируем команду для импорта
|
||||
cmd = [legendary_path, "import", app_name, folder_path]
|
||||
|
||||
try:
|
||||
# Выполняем команду legendary import
|
||||
def run_import():
|
||||
cmd = [self.legendary_path, "import", app_name, folder_path]
|
||||
subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Successfully imported '{0}' to Legendary").format(game_name), 3000
|
||||
_("Importing '{0}' to Legendary...").format(game_name), 0
|
||||
)
|
||||
logger.debug("Direct status message: Importing '%s' to Legendary", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when importing '%s'", game_name)
|
||||
threading.Thread(target=run_import, daemon=True).start()
|
||||
|
||||
def uninstall_egs_game(self, game_name: str, app_name: str):
|
||||
"""
|
||||
Uninstalls an Epic Games Store game using Legendary asynchronously.
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent,
|
||||
Args:
|
||||
game_name: The display name of the game.
|
||||
app_name: The Legendary app_name (unique identifier for the game).
|
||||
"""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
if not os.path.exists(self.legendary_path):
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to import '{0}' to Legendary: {1}").format(game_name, e.stderr)
|
||||
_("Legendary executable not found at {0}").format(self.legendary_path)
|
||||
)
|
||||
except FileNotFoundError:
|
||||
QMessageBox.warning(
|
||||
self.parent,
|
||||
_("Error"),
|
||||
_("Legendary executable not found")
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self.parent,
|
||||
_("Error"),
|
||||
_("Unexpected error during import: {0}").format(str(e))
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self.parent,
|
||||
_("Confirm Uninstallation"),
|
||||
_("Are you sure you want to uninstall '{0}'? This will remove the game files.").format(game_name),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
def run_uninstall():
|
||||
cmd = [self.legendary_path, "uninstall", app_name, "--skip-uninstaller"]
|
||||
try:
|
||||
subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
env={"LEGENDARY_CONFIG_PATH": self.legendary_config_path}
|
||||
)
|
||||
self.signals.show_status_message.emit(
|
||||
_("Successfully uninstalled '{0}'").format(game_name), 3000
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.signals.show_status_message.emit(
|
||||
_("Failed to uninstall '{0}'").format(game_name), 3000
|
||||
)
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to uninstall '{0}': {1}").format(game_name, e.stderr)
|
||||
)
|
||||
except FileNotFoundError:
|
||||
self.signals.show_status_message.emit(
|
||||
_("Legendary executable not found"), 3000
|
||||
)
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Legendary executable not found")
|
||||
)
|
||||
except Exception as e:
|
||||
self.signals.show_status_message.emit(
|
||||
_("Unexpected error during uninstall"), 3000
|
||||
)
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Unexpected error during uninstall: {0}").format(str(e))
|
||||
)
|
||||
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Uninstalling '{0}'...").format(game_name), 0
|
||||
)
|
||||
logger.debug("Direct status message: Uninstalling '%s'", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when uninstalling '%s'", game_name)
|
||||
threading.Thread(target=run_uninstall, daemon=True).start()
|
||||
|
||||
def toggle_favorite(self, game_card, add: bool):
|
||||
"""
|
||||
@ -179,18 +390,33 @@ class ContextMenuManager:
|
||||
if add and game_card.name not in favorites:
|
||||
favorites.append(game_card.name)
|
||||
game_card.is_favorite = True
|
||||
self.parent.statusBar().showMessage(_("Added '{0}' to favorites").format(game_card.name), 3000)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Added '{0}' to favorites").format(game_card.name), 3000
|
||||
)
|
||||
logger.debug("Direct status message: Added '%s' to favorites", game_card.name)
|
||||
else:
|
||||
logger.warning("Status bar not available when adding '%s' to favorites", game_card.name)
|
||||
elif not add and game_card.name in favorites:
|
||||
favorites.remove(game_card.name)
|
||||
game_card.is_favorite = False
|
||||
self.parent.statusBar().showMessage(_("Removed '{0}' from favorites").format(game_card.name), 3000)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Removed '{0}' from favorites").format(game_card.name), 3000
|
||||
)
|
||||
logger.debug("Direct status message: Removed '%s' from favorites", game_card.name)
|
||||
else:
|
||||
logger.warning("Status bar not available when removing '%s' from favorites", game_card.name)
|
||||
save_favorites(favorites)
|
||||
game_card.update_favorite_icon()
|
||||
|
||||
def _check_portproton(self):
|
||||
"""Check if PortProton is available."""
|
||||
if self.portproton_location is None:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found."))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("PortProton is not found.")
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -214,33 +440,32 @@ class ContextMenuManager:
|
||||
if entry:
|
||||
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
||||
if not exec_line:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("No executable command found in .desktop for game: {0}").format(game_name)
|
||||
)
|
||||
return None
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to parse .desktop file for game: {0}").format(game_name)
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Error reading .desktop file: {0}").format(e)
|
||||
)
|
||||
return None
|
||||
else:
|
||||
# Fallback: Search all .desktop files
|
||||
for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")):
|
||||
entry = parse_desktop_entry(file)
|
||||
if entry:
|
||||
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
||||
if exec_line:
|
||||
return exec_line
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_(".desktop file not found for game: {0}").format(game_name)
|
||||
)
|
||||
return None
|
||||
@ -251,8 +476,8 @@ class ContextMenuManager:
|
||||
try:
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Invalid executable command: {0}").format(exec_line)
|
||||
)
|
||||
return None
|
||||
@ -263,15 +488,15 @@ class ContextMenuManager:
|
||||
else:
|
||||
exe_path = entry_exec_split[-1]
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Executable file not found: {0}").format(exe_path or "None")
|
||||
)
|
||||
return None
|
||||
return exe_path
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to parse executable command: {0}").format(e)
|
||||
)
|
||||
return None
|
||||
@ -280,10 +505,17 @@ class ContextMenuManager:
|
||||
"""Remove a file and handle errors."""
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
|
||||
logger.debug("Direct status message: %s", success_message.format(game_name))
|
||||
else:
|
||||
logger.warning("Status bar not available when removing file for '%s'", game_name)
|
||||
return True
|
||||
except OSError as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), error_message.format(e))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
error_message.format(e)
|
||||
)
|
||||
return False
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
@ -304,13 +536,12 @@ class ContextMenuManager:
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not os.path.exists(desktop_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Get exec_line and parse exe_path
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@ -318,7 +549,6 @@ class ContextMenuManager:
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
|
||||
|
||||
# Remove .desktop file
|
||||
if not self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to delete .desktop file: {0}"),
|
||||
@ -327,7 +557,6 @@ class ContextMenuManager:
|
||||
):
|
||||
return
|
||||
|
||||
# Remove custom data if we got an exe_name
|
||||
if exe_name:
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
@ -338,15 +567,11 @@ class ContextMenuManager:
|
||||
try:
|
||||
shutil.rmtree(custom_folder)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to delete custom data: {0}").format(e)
|
||||
)
|
||||
|
||||
# Refresh UI
|
||||
self.parent.games = self.load_games()
|
||||
self.update_game_grid()
|
||||
|
||||
def add_to_menu(self, game_name, exec_line):
|
||||
"""Copy the .desktop file to ~/.local/share/applications."""
|
||||
if not self._check_portproton():
|
||||
@ -354,25 +579,29 @@ class ContextMenuManager:
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not os.path.exists(desktop_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Destination path
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
os.makedirs(applications_dir, exist_ok=True)
|
||||
dest_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||
|
||||
# Copy .desktop file
|
||||
try:
|
||||
shutil.copyfile(desktop_path, dest_path)
|
||||
os.chmod(dest_path, 0o755) # Ensure executable permissions
|
||||
self.parent.statusBar().showMessage(_("Game '{0}' added to menu").format(game_name), 3000)
|
||||
os.chmod(dest_path, 0o755)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Game '{0}' added to menu").format(game_name), 3000
|
||||
)
|
||||
logger.debug("Direct status message: Game '%s' added to menu", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when adding '%s' to menu", game_name)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to add game to menu: {0}").format(str(e))
|
||||
)
|
||||
|
||||
@ -394,25 +623,29 @@ class ContextMenuManager:
|
||||
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not os.path.exists(desktop_path):
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Destination path
|
||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
dest_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
||||
|
||||
# Copy .desktop file
|
||||
try:
|
||||
shutil.copyfile(desktop_path, dest_path)
|
||||
os.chmod(dest_path, 0o755) # Ensure executable permissions
|
||||
self.parent.statusBar().showMessage(_("Game '{0}' added to desktop").format(game_name), 3000)
|
||||
os.chmod(dest_path, 0o755)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Game '{0}' added to desktop").format(game_name), 3000
|
||||
)
|
||||
logger.debug("Direct status message: Game '%s' added to desktop", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when adding '%s' to desktop", game_name)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(
|
||||
self.parent, _("Error"),
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to add game to desktop: {0}").format(str(e))
|
||||
)
|
||||
|
||||
@ -429,7 +662,6 @@ class ContextMenuManager:
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path):
|
||||
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@ -441,7 +673,6 @@ class ContextMenuManager:
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
# Open dialog in edit mode
|
||||
dialog = AddGameDialog(
|
||||
parent=self.parent,
|
||||
theme=self.theme,
|
||||
@ -457,16 +688,20 @@ class ContextMenuManager:
|
||||
new_cover_path = dialog.coverEdit.text().strip()
|
||||
|
||||
if not new_name or not new_exe_path:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Game name and executable path are required."))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Game name and executable path are required.")
|
||||
)
|
||||
return
|
||||
|
||||
# Generate new .desktop file content
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to generate .desktop file data."))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to generate .desktop file data.")
|
||||
)
|
||||
return
|
||||
|
||||
# If the name has changed, remove the old .desktop file
|
||||
old_desktop_path = self._get_desktop_path(game_name)
|
||||
if game_name != new_name and os.path.exists(old_desktop_path):
|
||||
self._remove_file(
|
||||
@ -476,16 +711,17 @@ class ContextMenuManager:
|
||||
game_name
|
||||
)
|
||||
|
||||
# Save the updated .desktop file
|
||||
try:
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
except OSError as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to save .desktop file: {0}").format(e))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to save .desktop file: {0}").format(e)
|
||||
)
|
||||
return
|
||||
|
||||
# Update custom cover if provided
|
||||
if os.path.isfile(new_cover_path):
|
||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||
xdg_data_home = os.getenv(
|
||||
@ -500,16 +736,14 @@ class ContextMenuManager:
|
||||
try:
|
||||
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
||||
except OSError as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to copy cover image: {0}").format(e))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to copy cover image: {0}").format(e)
|
||||
)
|
||||
return
|
||||
|
||||
# Refresh the game list
|
||||
self.parent.games = self.load_games()
|
||||
self.update_game_grid()
|
||||
|
||||
def add_to_steam(self, game_name, exec_line, cover_path):
|
||||
"""Handle adding a non-Steam game to Steam via steam_api."""
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
@ -521,37 +755,174 @@ class ContextMenuManager:
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self.parent, _("Restart Steam"),
|
||||
_("The game was added successfully.\nPlease restart Steam for changes to take effect.")
|
||||
def on_add_to_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
self.signals.show_status_message.emit(
|
||||
_("The game was added successfully. Please restart Steam for changes to take effect."), 5000
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Adding '{0}' to Steam...").format(game_name), 0
|
||||
)
|
||||
logger.debug("Direct status message: Adding '%s' to Steam", game_name)
|
||||
else:
|
||||
QMessageBox.warning(self.parent, _("Error"), message)
|
||||
|
||||
def remove_from_steam(self, game_name, exec_line):
|
||||
"""Handle removing a non-Steam game from Steam via steam_api."""
|
||||
logger.warning("Status bar not available when adding '%s' to Steam", game_name)
|
||||
add_to_steam(game_name, exec_line, cover_path)
|
||||
|
||||
def remove_from_steam(self, game_name, exec_line, game_source):
|
||||
"""Handle removing a game from Steam via steam_api, supporting both EGS and non-EGS games."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
self.signals.show_status_message.emit(
|
||||
_("The game was removed successfully. Please restart Steam for changes to take effect."), 5000
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
if game_source == "epic":
|
||||
# For EGS games, construct the script path used in Steam shortcuts.vdf
|
||||
if not self.portproton_location:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("PortProton directory not found")
|
||||
)
|
||||
return
|
||||
steam_scripts_dir = os.path.join(self.portproton_location, "steam_scripts")
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
|
||||
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh")
|
||||
quoted_script_path = f'"{script_path}"'
|
||||
|
||||
# Directly remove the shortcut by matching AppName and Exe
|
||||
try:
|
||||
from portprotonqt.steam_api import get_steam_home, get_last_steam_user, convert_steam_id
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
self._show_warning_dialog(_("Error"), _("Steam directory not found"))
|
||||
return
|
||||
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
self._show_warning_dialog(_("Error"), _("Failed to get Steam user ID"))
|
||||
return
|
||||
|
||||
userdata_dir = os.path.join(steam_home, "userdata")
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
||||
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
|
||||
if not os.path.exists(steam_shortcuts_path):
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Steam shortcuts file not found")
|
||||
)
|
||||
return
|
||||
|
||||
# Backup shortcuts.vdf
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to create backup of shortcuts.vdf: {0}").format(e)
|
||||
)
|
||||
return
|
||||
|
||||
# Load shortcuts.vdf
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'rb') as f:
|
||||
shortcuts_data = vdf.binary_load(f)
|
||||
except Exception as e:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to load shortcuts.vdf: {0}").format(e)
|
||||
)
|
||||
return
|
||||
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
modified = False
|
||||
new_shortcuts = {}
|
||||
index = 0
|
||||
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
modified = True
|
||||
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
|
||||
continue
|
||||
new_shortcuts[str(index)] = entry
|
||||
index += 1
|
||||
|
||||
if not modified:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Game '{0}' not found in Steam shortcuts").format(game_name)
|
||||
)
|
||||
return
|
||||
|
||||
# Save updated shortcuts.vdf
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||
logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
|
||||
on_remove_from_steam_result((True, f"Game '{game_name}' removed from Steam"))
|
||||
except Exception as e:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to update shortcuts.vdf: {0}").format(e)
|
||||
)
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
shutil.copy2(backup_path, steam_shortcuts_path)
|
||||
logger.info("Restored shortcuts.vdf from backup")
|
||||
except Exception as restore_err:
|
||||
logger.error(f"Failed to restore shortcuts.vdf: {restore_err}")
|
||||
on_remove_from_steam_result((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
# Optionally, remove the script file
|
||||
if os.path.exists(script_path):
|
||||
try:
|
||||
os.remove(script_path)
|
||||
logger.info(f"Removed EGS script file: {script_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove EGS script file {script_path}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to remove EGS game from Steam: {0}").format(e)
|
||||
)
|
||||
on_remove_from_steam_result((False, f"Failed to remove EGS game from Steam: {e}"))
|
||||
return
|
||||
|
||||
success, message = remove_from_steam(game_name, exec_line)
|
||||
if success:
|
||||
QMessageBox.information(
|
||||
self.parent, _("Restart Steam"),
|
||||
_("The game was removed successfully.\nPlease restart Steam for changes to take effect.")
|
||||
)
|
||||
else:
|
||||
QMessageBox.warning(self.parent, _("Error"), message)
|
||||
# For non-EGS games, use the existing logic
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Removing '{0}' from Steam...").format(game_name), 0
|
||||
)
|
||||
logger.debug("Direct status message: Removing '%s' from Steam", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when removing '%s' from Steam", game_name)
|
||||
remove_from_steam(game_name, exec_line)
|
||||
|
||||
def open_game_folder(self, game_name, exec_line):
|
||||
"""Open the folder containing the game's executable."""
|
||||
@ -569,6 +940,15 @@ class ContextMenuManager:
|
||||
try:
|
||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||
self.parent.statusBar().showMessage(_("Opened folder for '{0}'").format(game_name), 3000)
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(
|
||||
_("Opened folder for '{0}'").format(game_name), 3000
|
||||
)
|
||||
logger.debug("Direct status message: Opened folder for '%s'", game_name)
|
||||
else:
|
||||
logger.warning("Status bar not available when opening folder for '%s'", game_name)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e)))
|
||||
self._show_warning_dialog(
|
||||
_("Error"),
|
||||
_("Failed to open game folder: {0}").format(str(e))
|
||||
)
|
||||
|
@ -95,6 +95,7 @@ class AddGameDialog(QDialog):
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
|
||||
layout = QFormLayout(self)
|
||||
layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# Game name
|
||||
self.nameEdit = QLineEdit(self)
|
||||
|
@ -14,11 +14,18 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.steam_api import get_weanticheatyet_status_async
|
||||
|
||||
from portprotonqt.steam_api import (
|
||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
||||
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail
|
||||
)
|
||||
import vdf
|
||||
import shutil
|
||||
import zlib
|
||||
from portprotonqt.downloader import Downloader
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
logger = get_logger(__name__)
|
||||
downloader = Downloader()
|
||||
|
||||
def get_egs_executable(app_name: str, legendary_config_path: str) -> str | None:
|
||||
"""Получает путь к исполняемому файлу EGS-игры из installed.json с использованием orjson."""
|
||||
@ -52,6 +59,237 @@ def get_cache_dir() -> Path:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||
"""
|
||||
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
|
||||
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
|
||||
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
|
||||
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
|
||||
Calls the callback with (success, message).
|
||||
"""
|
||||
if not app_name or not app_name.strip() or not game_title or not game_title.strip():
|
||||
logger.error("Invalid app_name or game_title: empty or whitespace")
|
||||
callback((False, "Game name or app name is empty or invalid"))
|
||||
return
|
||||
|
||||
if not os.path.exists(legendary_path):
|
||||
logger.error(f"Legendary executable not found: {legendary_path}")
|
||||
callback((False, f"Legendary executable not found: {legendary_path}"))
|
||||
return
|
||||
|
||||
portproton_dir = get_portproton_location()
|
||||
if not portproton_dir:
|
||||
logger.error("PortProton directory not found")
|
||||
callback((False, "PortProton directory not found"))
|
||||
return
|
||||
|
||||
# Determine wrapper
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
|
||||
if portproton_dir is not None and ".var" not in portproton_dir:
|
||||
wrapper = start_sh_path
|
||||
if not os.path.exists(start_sh_path):
|
||||
logger.error(f"start.sh not found at {start_sh_path}")
|
||||
callback((False, f"start.sh not found at {start_sh_path}"))
|
||||
return
|
||||
|
||||
# Create launch script
|
||||
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
||||
os.makedirs(steam_scripts_dir, exist_ok=True)
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_title.strip())
|
||||
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh")
|
||||
legendary_config_path = os.path.dirname(legendary_path)
|
||||
|
||||
script_content = f"""#!/usr/bin/env bash
|
||||
export LD_PRELOAD=
|
||||
export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
"{legendary_path}" launch {app_name} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}" "$@"
|
||||
"""
|
||||
try:
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
f.write(script_content)
|
||||
os.chmod(script_path, 0o755)
|
||||
logger.info(f"Created launch script for EGS game: {script_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create launch script {script_path}: {e}")
|
||||
callback((False, f"Failed to create launch script: {e}"))
|
||||
return
|
||||
|
||||
# Generate thumbnail
|
||||
generated_icon_path = os.path.join(portproton_dir, "data", "img", f"{safe_game_name}_egs.png")
|
||||
try:
|
||||
img_dir = os.path.join(portproton_dir, "data", "img")
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
game_exe = get_egs_executable(app_name, legendary_config_path)
|
||||
if not game_exe or not os.path.exists(game_exe):
|
||||
logger.warning(f"Executable not found for {app_name}, skipping thumbnail generation")
|
||||
icon_path = ""
|
||||
elif os.path.exists(generated_icon_path):
|
||||
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
|
||||
icon_path = generated_icon_path
|
||||
else:
|
||||
success = generate_thumbnail(game_exe, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"generate_thumbnail failed for {game_exe}")
|
||||
icon_path = ""
|
||||
else:
|
||||
logger.info(f"Generated thumbnail: {generated_icon_path}")
|
||||
icon_path = generated_icon_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail for {app_name}: {e}")
|
||||
icon_path = ""
|
||||
|
||||
# Get Steam directories
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
logger.error("Steam home directory not found")
|
||||
callback((False, "Steam directory not found"))
|
||||
return
|
||||
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
callback((False, "Failed to get Steam user ID"))
|
||||
return
|
||||
|
||||
userdata_dir = steam_home / "userdata"
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = userdata_dir / str(unsigned_id)
|
||||
steam_shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||
grid_dir = user_dir / "config" / "grid"
|
||||
os.makedirs(grid_dir, exist_ok=True)
|
||||
|
||||
# Backup shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
# Generate unique appid
|
||||
unique_string = f"{script_path}{game_title}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
else:
|
||||
aidvdf = appid
|
||||
|
||||
steam_appid = None
|
||||
downloaded_count = 0
|
||||
total_covers = 4
|
||||
download_lock = threading.Lock()
|
||||
|
||||
def on_cover_download(cover_file: str, cover_type: str):
|
||||
nonlocal downloaded_count
|
||||
try:
|
||||
if cover_file and os.path.exists(cover_file):
|
||||
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
finalize_shortcut()
|
||||
|
||||
def finalize_shortcut():
|
||||
tags_dict = {'0': 'PortProton'}
|
||||
shortcut = {
|
||||
"appid": aidvdf,
|
||||
"AppName": game_title,
|
||||
"Exe": f'"{script_path}"',
|
||||
"StartDir": f'"{os.path.dirname(script_path)}"',
|
||||
"icon": icon_path,
|
||||
"LaunchOptions": "",
|
||||
"IsHidden": 0,
|
||||
"AllowDesktopConfig": 1,
|
||||
"AllowOverlay": 1,
|
||||
"openvr": 0,
|
||||
"Devkit": 0,
|
||||
"DevkitGameID": "",
|
||||
"LastPlayTime": 0,
|
||||
"tags": tags_dict
|
||||
}
|
||||
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
||||
|
||||
try:
|
||||
if not os.path.exists(steam_shortcuts_path):
|
||||
os.makedirs(os.path.dirname(steam_shortcuts_path), exist_ok=True)
|
||||
open(steam_shortcuts_path, 'wb').close()
|
||||
|
||||
try:
|
||||
if os.path.getsize(steam_shortcuts_path) > 0:
|
||||
with open(steam_shortcuts_path, 'rb') as f:
|
||||
shortcuts_data = vdf.binary_load(f)
|
||||
else:
|
||||
shortcuts_data = {"shortcuts": {}}
|
||||
except Exception as load_err:
|
||||
logger.warning(f"Failed to load shortcuts.vdf, starting fresh: {load_err}")
|
||||
shortcuts_data = {"shortcuts": {}}
|
||||
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_title and entry.get("Exe") == f'"{script_path}"':
|
||||
logger.info(f"EGS game '{game_title}' already exists in Steam shortcuts")
|
||||
callback((False, f"Game '{game_title}' already exists in Steam"))
|
||||
return
|
||||
|
||||
new_index = str(len(shortcuts))
|
||||
shortcuts[new_index] = shortcut
|
||||
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
shutil.copy2(backup_path, steam_shortcuts_path)
|
||||
logger.info("Restored shortcuts.vdf from backup")
|
||||
except Exception as restore_err:
|
||||
logger.error(f"Failed to restore shortcuts.vdf: {restore_err}")
|
||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
logger.info(f"EGS game '{game_title}' added to Steam")
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
nonlocal steam_appid
|
||||
steam_apps, steam_apps_index = steam_data
|
||||
matching_app = search_app(game_title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
|
||||
if not steam_appid:
|
||||
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
||||
finalize_shortcut()
|
||||
return
|
||||
|
||||
cover_types = [
|
||||
(".jpg", "header.jpg"),
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
("_logo.png", "logo.png")
|
||||
]
|
||||
|
||||
for suffix, cover_type in cover_types:
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
|
||||
)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps)
|
||||
|
||||
def get_egs_game_description_async(
|
||||
app_name: str,
|
||||
callback: Callable[[str], None],
|
||||
@ -354,6 +592,7 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
||||
"""
|
||||
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
|
||||
Читает статистику времени игры и последнего запуска из файла statistics.
|
||||
Проверяет наличие игры в Steam для получения ProtonDB статуса.
|
||||
"""
|
||||
logger.debug("Starting to load Epic Games Store games")
|
||||
games: list[tuple] = []
|
||||
@ -489,39 +728,54 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
||||
|
||||
def on_description_fetched(api_description: str):
|
||||
final_description = api_description or _("No description available")
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
steam_apps, steam_apps_index = steam_data
|
||||
matching_app = search_app(title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
|
||||
def on_cover_loaded(pixmap: QPixmap):
|
||||
def on_anticheat_status(status: str):
|
||||
nonlocal pending_images
|
||||
with results_lock:
|
||||
game_results[index] = (
|
||||
title,
|
||||
final_description,
|
||||
local_path if os.path.exists(local_path) else "",
|
||||
app_name,
|
||||
f"legendary:launch:{app_name}",
|
||||
"",
|
||||
last_launch, # Время последнего запуска
|
||||
formatted_playtime, # Форматированное время игры
|
||||
"",
|
||||
status or "",
|
||||
last_launch_timestamp, # Временная метка последнего запуска
|
||||
playtime_seconds, # Время игры в секундах
|
||||
"epic"
|
||||
)
|
||||
pending_images -= 1
|
||||
update_progress(total_games - pending_images)
|
||||
if pending_images == 0:
|
||||
final_games = [game_results[i] for i in sorted(game_results.keys())]
|
||||
callback(final_games)
|
||||
def on_protondb_tier(protondb_tier: str):
|
||||
def on_description_fetched(api_description: str):
|
||||
final_description = api_description or _("No description available")
|
||||
|
||||
get_weanticheatyet_status_async(title, on_anticheat_status)
|
||||
def on_cover_loaded(pixmap: QPixmap):
|
||||
def on_anticheat_status(status: str):
|
||||
nonlocal pending_images
|
||||
with results_lock:
|
||||
game_results[index] = (
|
||||
title,
|
||||
final_description,
|
||||
local_path if os.path.exists(local_path) else "",
|
||||
app_name,
|
||||
f"legendary:launch:{app_name}",
|
||||
"",
|
||||
last_launch, # Время последнего запуска
|
||||
formatted_playtime, # Форматированное время игры
|
||||
protondb_tier, # ProtonDB tier
|
||||
status or "",
|
||||
last_launch_timestamp, # Временная метка последнего запуска
|
||||
playtime_seconds, # Время игры в секундах
|
||||
"epic"
|
||||
)
|
||||
pending_images -= 1
|
||||
update_progress(total_games - pending_images)
|
||||
if pending_images == 0:
|
||||
final_games = [game_results[i] for i in sorted(game_results.keys())]
|
||||
callback(final_games)
|
||||
|
||||
load_pixmap_async(cover_url, 600, 900, on_cover_loaded, app_name=app_name)
|
||||
get_weanticheatyet_status_async(title, on_anticheat_status)
|
||||
|
||||
get_egs_game_description_async(title, on_description_fetched)
|
||||
load_pixmap_async(cover_url, 600, 900, on_cover_loaded, app_name=app_name)
|
||||
|
||||
get_egs_game_description_async(title, on_description_fetched)
|
||||
|
||||
if steam_appid:
|
||||
logger.info(f"Found Steam appid {steam_appid} for EGS game {title}")
|
||||
get_protondb_tier_async(steam_appid, on_protondb_tier)
|
||||
else:
|
||||
logger.debug(f"No Steam app found for EGS game {title}")
|
||||
on_protondb_tier("") # Proceed with empty ProtonDB tier
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps)
|
||||
|
||||
max_workers = min(4, len(valid_games))
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
|
@ -26,6 +26,7 @@ class GameCard(QFrame):
|
||||
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
||||
openGameFolderRequested = Signal(str, str) # name, exec_line
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
|
||||
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, game_source,
|
||||
@ -67,14 +68,14 @@ class GameCard(QFrame):
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
# Параметры анимации обводки
|
||||
self._borderWidth = 2
|
||||
self._gradientAngle = 0.0
|
||||
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
|
||||
# Анимации
|
||||
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(300)
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
self.gradient_anim = None
|
||||
self.pulse_anim = None
|
||||
|
||||
@ -448,10 +449,8 @@ class GameCard(QFrame):
|
||||
if self._hovered or self._focused:
|
||||
center = self.rect().center()
|
||||
gradient = QConicalGradient(center, self._gradientAngle)
|
||||
gradient.setColorAt(0, QColor("#00fff5"))
|
||||
gradient.setColorAt(0.33, QColor("#FF5733"))
|
||||
gradient.setColorAt(0.66, QColor("#9B59B6"))
|
||||
gradient.setColorAt(1, QColor("#00fff5"))
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
@ -468,23 +467,25 @@ class GameCard(QFrame):
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(800)
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, 8)
|
||||
self.pulse_anim.setKeyValueAt(0.5, 10)
|
||||
self.pulse_anim.setKeyValueAt(1, 8)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.start()
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.hoverChanged.emit(self.name, True)
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(8)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
@ -492,9 +493,9 @@ class GameCard(QFrame):
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(3000)
|
||||
self.gradient_anim.setStartValue(360)
|
||||
self.gradient_anim.setEndValue(0)
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
@ -515,52 +516,58 @@ class GameCard(QFrame):
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.setBorderWidth(2)
|
||||
self.update()
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
self._focused = True
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(12)
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
if not self._hovered:
|
||||
self._focused = True
|
||||
self.focusChanged.emit(self.name, True)
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(3000)
|
||||
self.gradient_anim.setStartValue(360)
|
||||
self.gradient_anim.setEndValue(0)
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self._focused = False
|
||||
if not self._hovered: # Сохраняем анимацию, если есть наведение
|
||||
self.focusChanged.emit(self.name, False)
|
||||
if not self._hovered:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(2)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
|
@ -40,6 +40,7 @@ from typing import cast
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from PySide6.QtWidgets import QSizePolicy
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@ -52,39 +53,6 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAcceptDrops(True)
|
||||
self.current_exec_line = None
|
||||
self.currentDetailPage = None
|
||||
self.current_play_button = None
|
||||
self.pending_games = []
|
||||
self.game_card_cache = {}
|
||||
self.pending_images = {}
|
||||
self.total_games = 0
|
||||
self.games_load_timer = QTimer(self)
|
||||
self.games_load_timer.setSingleShot(True)
|
||||
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
||||
self.games_loaded.connect(self.on_games_loaded)
|
||||
self.current_add_game_dialog = None
|
||||
self.current_hovered_card = None
|
||||
|
||||
# Добавляем таймер для дебаунсинга сохранения настроек
|
||||
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_cache
|
||||
self.legendary_config_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQt", "legendary_cache"
|
||||
)
|
||||
os.makedirs(self.legendary_config_path, exist_ok=True)
|
||||
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
|
||||
|
||||
self.legendary_path = os.path.join(self.legendary_config_path, "legendary")
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
# Создаём менеджер тем и читаем, какая тема выбрана
|
||||
self.theme_manager = ThemeManager()
|
||||
selected_theme = read_theme_from_config()
|
||||
@ -117,9 +85,47 @@ class MainWindow(QMainWindow):
|
||||
self.updateGameGrid
|
||||
)
|
||||
|
||||
QApplication.setStyle("Fusion")
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
|
||||
self.setAcceptDrops(True)
|
||||
self.current_exec_line = None
|
||||
self.currentDetailPage = None
|
||||
self.current_play_button = None
|
||||
self.current_focused_card = None
|
||||
self.pending_games = []
|
||||
self.game_card_cache = {}
|
||||
self.pending_images = {}
|
||||
self.total_games = 0
|
||||
self.games_load_timer = QTimer(self)
|
||||
self.games_load_timer.setSingleShot(True)
|
||||
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
||||
self.games_loaded.connect(self.on_games_loaded)
|
||||
self.current_add_game_dialog = None
|
||||
self.current_hovered_card = None
|
||||
|
||||
# Добавляем таймер для дебаунсинга сохранения настроек
|
||||
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_cache
|
||||
self.legendary_config_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQt", "legendary_cache"
|
||||
)
|
||||
os.makedirs(self.legendary_config_path, exist_ok=True)
|
||||
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
|
||||
|
||||
self.legendary_path = os.path.join(self.legendary_config_path, "legendary")
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
# Статус-бар
|
||||
self.setStatusBar(QStatusBar(self))
|
||||
self.statusBar().setStyleSheet(self.theme.STATUS_BAR_STYLE)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
|
||||
self.progress_bar.setMaximumWidth(200)
|
||||
self.progress_bar.setTextVisible(True)
|
||||
self.progress_bar.setVisible(False)
|
||||
@ -200,8 +206,6 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.restore_state()
|
||||
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
|
||||
self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
|
||||
self.input_manager = InputManager(self)
|
||||
QTimer.singleShot(0, self.loadGames)
|
||||
|
||||
@ -242,10 +246,39 @@ class MainWindow(QMainWindow):
|
||||
self.updateGameGrid()
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
def _on_card_focused(self, game_name: str, is_focused: bool):
|
||||
"""Обработчик сигнала focusChanged от GameCard."""
|
||||
card_key = None
|
||||
for key, card in self.game_card_cache.items():
|
||||
if card.name == game_name:
|
||||
card_key = key
|
||||
break
|
||||
|
||||
if not card_key:
|
||||
return
|
||||
|
||||
card = self.game_card_cache[card_key]
|
||||
|
||||
if is_focused:
|
||||
# Если карточка получила фокус
|
||||
if self.current_hovered_card and self.current_hovered_card != card:
|
||||
# Сбрасываем текущую hovered карточку
|
||||
self.current_hovered_card._hovered = False
|
||||
self.current_hovered_card.leaveEvent(None)
|
||||
self.current_hovered_card = None
|
||||
if self.current_focused_card and self.current_focused_card != card:
|
||||
# Сбрасываем текущую focused карточку
|
||||
self.current_focused_card._focused = False
|
||||
self.current_focused_card.clearFocus()
|
||||
self.current_focused_card = card
|
||||
else:
|
||||
# Если карточка потеряла фокус
|
||||
if self.current_focused_card == card:
|
||||
self.current_focused_card = None
|
||||
|
||||
def _on_card_hovered(self, game_name: str, is_hovered: bool):
|
||||
"""Обработчик сигнала hoverChanged от GameCard."""
|
||||
card_key = None
|
||||
# Находим ключ карточки по имени игры
|
||||
for key, card in self.game_card_cache.items():
|
||||
if card.name == game_name:
|
||||
card_key = key
|
||||
@ -258,10 +291,14 @@ class MainWindow(QMainWindow):
|
||||
|
||||
if is_hovered:
|
||||
# Если мышь наведена на карточку
|
||||
if self.current_focused_card and self.current_focused_card != card:
|
||||
# Сбрасываем текущую focused карточку
|
||||
self.current_focused_card._focused = False
|
||||
self.current_focused_card.clearFocus()
|
||||
if self.current_hovered_card and self.current_hovered_card != card:
|
||||
# Сбрасываем предыдущую выделенную карточку
|
||||
# Сбрасываем предыдущую hovered карточку
|
||||
self.current_hovered_card._hovered = False
|
||||
self.current_hovered_card.leaveEvent(None) # Принудительно вызываем leaveEvent
|
||||
self.current_hovered_card.leaveEvent(None)
|
||||
self.current_hovered_card = card
|
||||
else:
|
||||
# Если мышь покинула карточку
|
||||
@ -721,6 +758,7 @@ class MainWindow(QMainWindow):
|
||||
context_menu_manager=self.context_menu_manager
|
||||
)
|
||||
card.hoverChanged.connect(self._on_card_hovered)
|
||||
card.focusChanged.connect(self._on_card_focused)
|
||||
# Подключаем сигналы контекстного меню
|
||||
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
||||
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
||||
@ -930,9 +968,11 @@ class MainWindow(QMainWindow):
|
||||
formLayout = QFormLayout()
|
||||
formLayout.setContentsMargins(0, 10, 0, 0)
|
||||
formLayout.setSpacing(10)
|
||||
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# 1. Time detail_level
|
||||
self.timeDetailCombo = QComboBox()
|
||||
self.timeDetailCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.time_keys = ["detailed", "brief"]
|
||||
self.time_labels = [_("detailed"), _("brief")]
|
||||
self.timeDetailCombo.addItems(self.time_labels)
|
||||
@ -951,6 +991,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# 2. Games sort_method
|
||||
self.gamesSortCombo = QComboBox()
|
||||
self.gamesSortCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.sort_keys = ["last_launch", "playtime", "alphabetical", "favorites"]
|
||||
self.sort_labels = [_("last launch"), _("playtime"), _("alphabetical"), _("favorites")]
|
||||
self.gamesSortCombo.addItems(self.sort_labels)
|
||||
@ -971,6 +1012,7 @@ class MainWindow(QMainWindow):
|
||||
self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"]
|
||||
self.filter_labels = [_("all"), "steam", "portproton", _("favorites"), "epic games store"]
|
||||
self.gamesDisplayCombo = QComboBox()
|
||||
self.gamesDisplayCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.gamesDisplayCombo.addItems(self.filter_labels)
|
||||
self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
|
||||
self.gamesDisplayCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@ -2068,7 +2110,6 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
|
||||
# Завершаем все игровые процессы
|
||||
for proc in self.game_processes:
|
||||
try:
|
||||
parent = psutil.Process(proc.pid)
|
||||
@ -2109,4 +2150,5 @@ class MainWindow(QMainWindow):
|
||||
self.checkProcessTimer.deleteLater()
|
||||
self.checkProcessTimer = None
|
||||
|
||||
QApplication.quit()
|
||||
event.accept()
|
||||
|
@ -20,8 +20,12 @@ class SystemOverlay(QDialog):
|
||||
self.theme_manager = ThemeManager()
|
||||
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
|
||||
|
||||
# Убираем рамку окна
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
||||
# Make window stay on top and frameless
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint |
|
||||
Qt.WindowType.Dialog |
|
||||
Qt.WindowType.WindowStaysOnTopHint
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
|
@ -8,6 +8,76 @@ current_theme_name = read_theme_from_config()
|
||||
favoriteLabelSize = 48, 48
|
||||
pixmapsScaledSize = 60, 60
|
||||
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||
# Значение в пикселях.
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора.
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой.
|
||||
# Значение в пикселях.
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе.
|
||||
# Значение в пикселях.
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
|
||||
# Значение в пикселях.
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет максимальную толщину рамки при пульсации.
|
||||
# Значение в пикселях.
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой.
|
||||
# Значение в миллисекундах.
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации.
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
|
||||
# Значение в миллисекундах.
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента.
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
|
||||
# Значение в миллисекундах.
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах).
|
||||
# Определяет начальную точку вращения градиента при старте анимации.
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах).
|
||||
# Определяет конечную точку вращения градиента.
|
||||
# Значение 0 означает полный поворот на 360 градусов.
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки.
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
]
|
||||
}
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = """
|
||||
QFrame {
|
||||
|
@ -8,6 +8,76 @@ current_theme_name = read_theme_from_config()
|
||||
favoriteLabelSize = 48, 48
|
||||
pixmapsScaledSize = 60, 60
|
||||
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||
# Значение в пикселях.
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора.
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой.
|
||||
# Значение в пикселях.
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе.
|
||||
# Значение в пикселях.
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
|
||||
# Значение в пикселях.
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет максимальную толщину рамки при пульсации.
|
||||
# Значение в пикселях.
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой.
|
||||
# Значение в миллисекундах.
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации.
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
|
||||
# Значение в миллисекундах.
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента.
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
|
||||
# Значение в миллисекундах.
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах).
|
||||
# Определяет начальную точку вращения градиента при старте анимации.
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах).
|
||||
# Определяет конечную точку вращения градиента.
|
||||
# Значение 0 означает полный поворот на 360 градусов.
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки.
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
]
|
||||
}
|
||||
|
||||
CONTEXT_MENU_STYLE = """
|
||||
QMenu {
|
||||
background: #282a33;;
|
||||
@ -38,6 +108,55 @@ CONTEXT_MENU_STYLE = """
|
||||
}
|
||||
"""
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
|
||||
MAIN_WINDOW_STYLE = """
|
||||
QWidget {
|
||||
background: #282a33;
|
||||
}
|
||||
QLabel {
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton {
|
||||
background: #3f424d;
|
||||
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-family: 'Play';
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #409EFF;
|
||||
border: 2px solid #409EFF;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #282a33;
|
||||
}
|
||||
QPushButton:focus {
|
||||
border: 2px solid #409EFF;
|
||||
background-color: #409EFF;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПРОГРЕСС-БАРА
|
||||
PROGRESS_BAR_STYLE = """
|
||||
QProgressBar {
|
||||
color: #ffffff;
|
||||
background-color: #3f424d;
|
||||
text-align: center;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #409EFF;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ СТАТУС-БАРА
|
||||
STATUS_BAR_STYLE = """
|
||||
QStatusBar {
|
||||
color: #ffffff;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = """
|
||||
QFrame {
|
||||
@ -63,7 +182,7 @@ TITLE_LABEL_STYLE = """
|
||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||
NAV_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: #282a33;
|
||||
background: transparent;
|
||||
border: 0px solid;
|
||||
}
|
||||
"""
|
||||
@ -94,16 +213,6 @@ NAV_BUTTON_STYLE = """
|
||||
}
|
||||
"""
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
|
||||
MAIN_WINDOW_STYLE = """
|
||||
QMainWindow {
|
||||
background: none;
|
||||
}
|
||||
QLabel {
|
||||
color: #232627;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПОЛЯ ПОИСКА
|
||||
SEARCH_EDIT_STYLE = """
|
||||
QLineEdit {
|
||||
@ -120,13 +229,6 @@ SEARCH_EDIT_STYLE = """
|
||||
}
|
||||
"""
|
||||
|
||||
SETTINGS_CHECKBOX_STYLE = """
|
||||
QCheckBox:focus {
|
||||
border: 2px solid #409EFF;
|
||||
background: #404554;
|
||||
}
|
||||
"""
|
||||
|
||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
|
||||
SCROLL_AREA_STYLE = """
|
||||
QWidget {
|
||||
@ -633,7 +735,8 @@ SETTINGS_COMBO_STYLE = f"""
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: #373a43;
|
||||
border: 1px solid #409EFF;
|
||||
border: 2px solid #409EFF;
|
||||
border-bottom-style: none;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
@ -671,15 +774,20 @@ SETTINGS_COMBO_STYLE = f"""
|
||||
/* Список при открытом комбобоксе */
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
border: 1px solid #409EFF;
|
||||
border: 2px solid #409EFF;
|
||||
border-top-style: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}}
|
||||
QListView {{
|
||||
background: #3f424d;
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
border-radius: 0px;
|
||||
margin: 0px 3px 3px 3px;
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
@ -698,8 +806,10 @@ SETTINGS_COMBO_STYLE = f"""
|
||||
SETTINGS_CHECKBOX_STYLE = f"""
|
||||
QCheckBox {{
|
||||
height: 34px;
|
||||
color: #ffffff;
|
||||
font-family: 'Play';
|
||||
font-size: 16px;
|
||||
}}
|
||||
|
||||
QCheckBox::indicator {{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
26
renovate.json
Normal file
26
renovate.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:best-practices"],
|
||||
"rebaseWhen": "never",
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"automerge": true,
|
||||
"matchUpdateTypes": ["pin", "pinDigest"]
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"]
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"matchFileNames": [".python-version"]
|
||||
}
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user