Compare commits
35 Commits
fdd5a0a3d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30f6f2e74 | ||
|
0231073b19
|
|||
|
dec24429f5
|
|||
|
4a758f3b3c
|
|||
|
0853dd1579
|
|||
|
bbb87c0455
|
|||
|
b32a71a125
|
|||
|
|
bddf9f850a | ||
|
|
a9c3cfa167 | ||
|
7675bc4cdc
|
|||
|
ffa203f019
|
|||
|
3eed25ecee
|
|||
|
3736bb279e
|
|||
|
|
b59ee5ae8e | ||
|
33176590fd
|
|||
|
8046065929
|
|||
|
|
fbad5add6c | ||
|
438e9737ea
|
|||
|
2d39a4c740
|
|||
|
567203b0b0
|
|||
|
502cbc5030
|
|||
|
9b61215152
|
|||
|
10d3fe8ab4
|
|||
|
a568ad9ef8
|
|||
|
f074843fc8
|
|||
|
4ab078b93e
|
|||
|
7df6ad3b80
|
|||
|
464ad0fe9c
|
|||
|
cde92885d4
|
|||
|
120c7b319c
|
|||
|
596aed0077
|
|||
|
6fc6cb1e02
|
|||
|
186e28a19b
|
|||
|
28e4d1e77c
|
|||
|
fff1f888c4
|
@@ -94,7 +94,7 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
|
||||
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.7
|
||||
VERSION: 0.1.8
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -188,4 +188,4 @@ jobs:
|
||||
tag_name: v${{ env.VERSION }}
|
||||
prerelease: true
|
||||
files: release/**/*
|
||||
sha256sum: true
|
||||
sha256sum: false
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
|
||||
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
|
||||
@@ -11,12 +11,12 @@ repos:
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.8.22
|
||||
rev: 0.9.5
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.0
|
||||
rev: v0.14.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
|
||||
|
||||
10
CHANGELOG.md
@@ -3,16 +3,24 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.8] - 2025-10-18
|
||||
|
||||
### Added
|
||||
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
|
||||
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
|
||||
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
|
||||
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
|
||||
|
||||
### Changed
|
||||
- При завершении автоустановки приложение больше не перезапускается
|
||||
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
|
||||
- Обновлены и дополнены скриншоты темы
|
||||
|
||||
### Fixed
|
||||
- Исправлено наложение карточек при смене фильтра игр
|
||||
- Исправлена невозможность запуска приложения без подключёного геймпада
|
||||
- Исправлена невозможность установки компонентов Winetricks через геймпад
|
||||
- Ресиверы и виртуальные устройства больше не считаются за геймпад
|
||||
|
||||
|
||||
### Contributors
|
||||
|
||||
@@ -36,7 +36,7 @@ AppDir:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.7
|
||||
version: 0.1.8
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
apt:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.7
|
||||
pkgver=0.1.8
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.7
|
||||
%global pypi_version 0.1.8
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
|
||||
@@ -1021,7 +1021,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "farlight 84",
|
||||
"status": "Supported"
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "riders republic",
|
||||
@@ -1436,8 +1436,8 @@
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "blue protocol",
|
||||
"status": "Broken"
|
||||
"normalized_name": "blue protocol star resonance",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dark and darker",
|
||||
|
||||
12972
data/games_appid.json
@@ -1,4 +1,108 @@
|
||||
[
|
||||
{
|
||||
"normalized_title": "split/second",
|
||||
"slug": "split-second"
|
||||
},
|
||||
{
|
||||
"normalized_title": "warzone 2100",
|
||||
"slug": "warzone-2100"
|
||||
},
|
||||
{
|
||||
"normalized_title": "foundation",
|
||||
"slug": "foundation"
|
||||
},
|
||||
{
|
||||
"normalized_title": "земский собор [демо]",
|
||||
"slug": "zemskij-sobor-demo"
|
||||
},
|
||||
{
|
||||
"normalized_title": "crusader kings 3",
|
||||
"slug": "crusader-kings-3"
|
||||
},
|
||||
{
|
||||
"normalized_title": "nadir a grimdark deck builder",
|
||||
"slug": "nadir-a-grimdark-deck-builder"
|
||||
},
|
||||
{
|
||||
"normalized_title": "oriental empires",
|
||||
"slug": "oriental-empires"
|
||||
},
|
||||
{
|
||||
"normalized_title": "vampire the masquerade bloodlines 2",
|
||||
"slug": "vampire-the-masquerade-bloodlines-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "escape from duckov",
|
||||
"slug": "escape-from-duckov"
|
||||
},
|
||||
{
|
||||
"normalized_title": "xiii",
|
||||
"slug": "xiii"
|
||||
},
|
||||
{
|
||||
"normalized_title": "saints row 2",
|
||||
"slug": "saints-row-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "frozenheim",
|
||||
"slug": "frozenheim"
|
||||
},
|
||||
{
|
||||
"normalized_title": "saints row (2022)",
|
||||
"slug": "saints-row-2022"
|
||||
},
|
||||
{
|
||||
"normalized_title": "iron harvest",
|
||||
"slug": "iron-harvest"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell blacklist",
|
||||
"slug": "tom-clancys-splinter-cell-blacklist"
|
||||
},
|
||||
{
|
||||
"normalized_title": "painkiller overdose",
|
||||
"slug": "painkiller-overdose"
|
||||
},
|
||||
{
|
||||
"normalized_title": "ancestors legacy",
|
||||
"slug": "ancestors-legacy"
|
||||
},
|
||||
{
|
||||
"normalized_title": "bye sweet carole",
|
||||
"slug": "bye-sweet-carole"
|
||||
},
|
||||
{
|
||||
"normalized_title": "painkiller black",
|
||||
"slug": "painkiller-black-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "hogwarts legacy",
|
||||
"slug": "hogwarts-legacy"
|
||||
},
|
||||
{
|
||||
"normalized_title": "active matter",
|
||||
"slug": "active-matter"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell",
|
||||
"slug": "tom-clancys-splinter-cell"
|
||||
},
|
||||
{
|
||||
"normalized_title": "sniper ghost warrior",
|
||||
"slug": "sniper-ghost-warrior"
|
||||
},
|
||||
{
|
||||
"normalized_title": "fate undiscovered realms",
|
||||
"slug": "fate-undiscovered-realms"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dying light the beast deluxe",
|
||||
"slug": "dying-light-the-beast-deluxe-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "spellforce platinum",
|
||||
"slug": "spellforce-platinum-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dirt rally 2.0 game of the year",
|
||||
"slug": "dirt-rally-2-0-game-of-the-year-edition"
|
||||
@@ -271,10 +375,6 @@
|
||||
"normalized_title": "steins;gate the distant valhalla",
|
||||
"slug": "steins-gate-the-distant-valhalla"
|
||||
},
|
||||
{
|
||||
"normalized_title": "hogwarts legacy",
|
||||
"slug": "hogwarts-legacy"
|
||||
},
|
||||
{
|
||||
"normalized_title": "osu!",
|
||||
"slug": "osu"
|
||||
|
||||
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 247 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 247 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 247 of 247 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 247 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 247 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 247 из 247 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
|
||||
from portprotonqt.config_utils import (
|
||||
save_fullscreen_config,
|
||||
read_fullscreen_config,
|
||||
get_portproton_start_command
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.cli import parse_args
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.7"
|
||||
__app_version__ = "0.1.8"
|
||||
|
||||
def get_version():
|
||||
try:
|
||||
commit = subprocess.check_output(
|
||||
['git', 'rev-parse', '--short', 'HEAD'],
|
||||
stderr=subprocess.DEVNULL
|
||||
).decode('utf-8').strip()
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8").strip()
|
||||
return f"{__app_version__} ({commit})"
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
return __app_version__
|
||||
|
||||
def main():
|
||||
os.environ['PW_CLI'] = '1'
|
||||
os.environ['PROCESS_LOG'] = '1'
|
||||
os.environ['START_FROM_STEAM'] = '1'
|
||||
os.environ["PW_CLI"] = "1"
|
||||
os.environ["PROCESS_LOG"] = "1"
|
||||
os.environ["START_FROM_STEAM"] = "1"
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
start_sh = get_portproton_start_command()
|
||||
|
||||
if portproton_path is None:
|
||||
if start_sh is None:
|
||||
return
|
||||
|
||||
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
|
||||
subprocess.run([script_path, 'cli', '--initial'])
|
||||
subprocess.run(start_sh + ["cli", "--initial"])
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||
@@ -43,41 +48,116 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parse_args()
|
||||
|
||||
# Setup logger with specified debug level
|
||||
setup_logger(args.debug_level)
|
||||
|
||||
# Reinitialize logger after setup to ensure it uses the new configuration
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
if socket.waitForConnected(200):
|
||||
# Второй экземпляр — передаём команду первому
|
||||
fullscreen = args.fullscreen or read_fullscreen_config()
|
||||
msg = b"show:fullscreen" if fullscreen else b"show"
|
||||
socket.write(msg)
|
||||
socket.flush()
|
||||
socket.waitForBytesWritten(500)
|
||||
socket.disconnectFromServer()
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Если старый сокет остался — удалить
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
if not local_server.listen(server_name):
|
||||
logger.warning(f"Failed to start local server: {local_server.errorString()}")
|
||||
return
|
||||
|
||||
# --- Qt translations ---
|
||||
system_locale = QLocale.system()
|
||||
qt_translator = QTranslator()
|
||||
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||||
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
|
||||
app.installTranslator(qt_translator)
|
||||
else:
|
||||
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
|
||||
logger.warning(
|
||||
f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
|
||||
)
|
||||
|
||||
# --- Main Window ---
|
||||
version = get_version()
|
||||
window = MainWindow(app_name=__app_name__, version=version)
|
||||
|
||||
if args.fullscreen:
|
||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
conn = local_server.nextPendingConnection()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
if conn.waitForReadyRead(1000):
|
||||
data = conn.readAll().data()
|
||||
msg = bytes(data).decode("utf-8", errors="ignore")
|
||||
logger.info(f"IPC message received: {msg}")
|
||||
|
||||
def restore_window():
|
||||
try:
|
||||
if msg.startswith("show"):
|
||||
if hasattr(window, "restore_from_tray"):
|
||||
window.restore_from_tray() # type: ignore[attr-defined]
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
window.setWindowState(
|
||||
window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
|
||||
)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
save_fullscreen_config(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
logger.info("Switching to normal window via IPC")
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to restore window: {e}")
|
||||
|
||||
# Выполняем в основном потоке
|
||||
QTimer.singleShot(0, restore_window)
|
||||
|
||||
conn.disconnectFromServer()
|
||||
|
||||
local_server.newConnection.connect(handle_new_connection)
|
||||
|
||||
# --- Initial fullscreen state ---
|
||||
launch_fullscreen = args.fullscreen or read_fullscreen_config()
|
||||
if launch_fullscreen:
|
||||
logger.info(
|
||||
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
|
||||
)
|
||||
save_fullscreen_config(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
logger.info("Launching in normal mode")
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
|
||||
# --- Cleanup ---
|
||||
def cleanup_on_exit():
|
||||
nonlocal window
|
||||
app.aboutToQuit.disconnect()
|
||||
if window:
|
||||
window.close()
|
||||
app.quit()
|
||||
try:
|
||||
local_server.close()
|
||||
QLocalServer.removeServer(server_name)
|
||||
if window:
|
||||
window.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup error: {e}")
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import configparser
|
||||
import shutil
|
||||
import subprocess
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_portproton_location = None
|
||||
_portproton_start_sh = None
|
||||
|
||||
# Paths to configuration files
|
||||
CONFIG_FILE = os.path.join(
|
||||
@@ -101,14 +103,14 @@ def read_file_content(file_path):
|
||||
return f.read().strip()
|
||||
|
||||
def get_portproton_location():
|
||||
"""Returns the path to the PortProton directory.
|
||||
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
|
||||
If the path is invalid, uses the default directory.
|
||||
"""
|
||||
"""Возвращает путь к PortProton каталогу (строку) или None."""
|
||||
global _portproton_location
|
||||
|
||||
if _portproton_location is not None:
|
||||
return _portproton_location
|
||||
|
||||
location = None
|
||||
|
||||
if os.path.isfile(PORTPROTON_CONFIG_FILE):
|
||||
try:
|
||||
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
|
||||
@@ -116,19 +118,46 @@ def get_portproton_location():
|
||||
_portproton_location = location
|
||||
logger.info(f"PortProton path from configuration: {location}")
|
||||
return _portproton_location
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}")
|
||||
|
||||
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_dir):
|
||||
_portproton_location = default_dir
|
||||
logger.info(f"Using flatpak PortProton directory: {default_dir}")
|
||||
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_flatpak_dir):
|
||||
_portproton_location = default_flatpak_dir
|
||||
logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
|
||||
return _portproton_location
|
||||
|
||||
logger.warning("PortProton configuration and flatpak directory not found")
|
||||
logger.warning("PortProton configuration and Flatpak directory not found")
|
||||
return None
|
||||
|
||||
def get_portproton_start_command():
|
||||
"""Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["flatpak", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if "ru.linux_gaming.PortProton" in result.stdout:
|
||||
logger.info("Detected Flatpak installation")
|
||||
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
|
||||
if os.path.exists(start_sh_path):
|
||||
return [start_sh_path]
|
||||
|
||||
logger.warning("Neither flatpak nor start.sh found for PortProton")
|
||||
return None
|
||||
|
||||
|
||||
def parse_desktop_entry(file_path):
|
||||
"""Reads and parses a .desktop file using configparser.
|
||||
Returns None if the [Desktop Entry] section is missing.
|
||||
@@ -177,6 +206,26 @@ def save_card_size(card_width):
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_auto_card_size():
|
||||
"""Reads the card size (width) for Auto Install from the [Cards] section.
|
||||
Returns 250 if the parameter is not set.
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
|
||||
save_auto_card_size(250)
|
||||
return 250
|
||||
return cp.getint("Cards", "auto_card_width", fallback=250)
|
||||
|
||||
def save_auto_card_size(card_width):
|
||||
"""Saves the card size (width) for Auto Install to the [Cards] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Cards" not in cp:
|
||||
cp["Cards"] = {}
|
||||
cp["Cards"]["auto_card_width"] = str(card_width)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
|
||||
def read_sort_method():
|
||||
"""Reads the sort method from the [Games] section.
|
||||
Returns 'last_launch' if the parameter is not set.
|
||||
@@ -427,3 +476,22 @@ def save_favorite_folders(folders):
|
||||
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_minimize_to_tray():
|
||||
"""Reads the minimize-to-tray setting from the [Display] section.
|
||||
Returns True if the parameter is missing (default: minimize to tray).
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
|
||||
save_minimize_to_tray(True)
|
||||
return True
|
||||
return cp.getboolean("Display", "minimize_to_tray", fallback=True)
|
||||
|
||||
def save_minimize_to_tray(minimize_to_tray):
|
||||
"""Saves the minimize-to-tray setting to the [Display] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
|
||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
||||
@@ -406,16 +406,7 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
# Construct EGS launch command
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
if self.portproton_location and ".var" not in self.portproton_location:
|
||||
wrapper = start_sh_path
|
||||
if not os.path.exists(start_sh_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("start.sh not found at {path}").format(path=start_sh_path)
|
||||
)
|
||||
return
|
||||
wrapper = get_portproton_start_command()
|
||||
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
|
||||
else:
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
|
||||
@@ -2,12 +2,11 @@ import os
|
||||
import tempfile
|
||||
import re
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QPixmap, QIcon, QTextCursor
|
||||
from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
|
||||
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget
|
||||
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox
|
||||
)
|
||||
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
from PIL import Image
|
||||
@@ -1034,8 +1033,8 @@ class AddGameDialog(QDialog):
|
||||
"""Обработчик выбора файла в FileExplorer"""
|
||||
self.exeEdit.setText(file_path)
|
||||
self.last_exe_path = file_path # Update last selected exe path
|
||||
if not self.edit_mode:
|
||||
# Автоматически заполняем имя игры, если не в режиме редактирования
|
||||
if not self.edit_mode and not self.nameEdit.text().strip():
|
||||
# Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную
|
||||
game_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
self.nameEdit.setText(game_name)
|
||||
|
||||
@@ -1674,3 +1673,555 @@ class WinetricksDialog(QDialog):
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_winetricks_mode()
|
||||
super().reject()
|
||||
class ExeSettingsDialog(QDialog):
|
||||
def __init__(self, parent=None, theme=None, exe_path=None):
|
||||
super().__init__(parent)
|
||||
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
|
||||
self.exe_path = exe_path
|
||||
if not self.exe_path:
|
||||
return
|
||||
self.portproton_path = get_portproton_location()
|
||||
if self.portproton_path is None:
|
||||
logger.error("PortProton location not found")
|
||||
return
|
||||
base_path = os.path.join(self.portproton_path, "data")
|
||||
self.start_sh = [os.path.join(base_path, "scripts", "start.sh")]
|
||||
|
||||
self.current_settings = {}
|
||||
self.value_widgets = {}
|
||||
self.original_values = {}
|
||||
self.advanced_widgets = {}
|
||||
self.original_display_values = {}
|
||||
self.available_keys = set()
|
||||
self.blocked_keys = set()
|
||||
self.numa_nodes = {}
|
||||
self.is_amd = False
|
||||
self.locale_options = []
|
||||
self.logical_core_options = []
|
||||
self.amd_vulkan_drivers = []
|
||||
self.branch_name = _("Unknown")
|
||||
|
||||
self.setWindowTitle(_("Exe Settings"))
|
||||
self.setModal(True)
|
||||
self.resize(900, 600)
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
|
||||
self.init_toggle_settings()
|
||||
self.setup_ui()
|
||||
|
||||
# Find input_manager and main_window
|
||||
self.input_manager = None
|
||||
self.main_window = None
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'input_manager'):
|
||||
self.input_manager = cast("MainWindow", parent).input_manager
|
||||
self.main_window = parent
|
||||
parent = parent.parent()
|
||||
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
|
||||
# Load current settings (includes list-db)
|
||||
self.load_current_settings()
|
||||
|
||||
def _get_process_args(self, subcommand_args):
|
||||
"""Get the full arguments for QProcess.start, handling flatpak separator."""
|
||||
if self.start_sh[0] == "flatpak":
|
||||
return self.start_sh[1:] + ["--"] + subcommand_args
|
||||
else:
|
||||
return self.start_sh + subcommand_args
|
||||
|
||||
def init_toggle_settings(self):
|
||||
"""Initialize predefined toggle settings with descriptions."""
|
||||
self.toggle_settings = {
|
||||
'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
|
||||
'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
|
||||
'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
|
||||
'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
|
||||
'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
|
||||
'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
|
||||
'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
|
||||
'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
|
||||
'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
|
||||
'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
|
||||
'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
|
||||
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
|
||||
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
|
||||
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
|
||||
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
|
||||
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
|
||||
'PW_USE_TERMINAL': _("Run the application in a terminal"),
|
||||
'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
|
||||
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
|
||||
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
|
||||
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
|
||||
'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
|
||||
'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
|
||||
'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
|
||||
'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
|
||||
'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
|
||||
'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
|
||||
'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
|
||||
'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
|
||||
'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
|
||||
'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
|
||||
'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
|
||||
'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
|
||||
'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
|
||||
'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
|
||||
'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
|
||||
'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
|
||||
'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
|
||||
'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
|
||||
}
|
||||
|
||||
def setup_ui(self):
|
||||
"""Set up the user interface."""
|
||||
self.main_layout = QVBoxLayout(self)
|
||||
self.main_layout.setContentsMargins(10, 10, 10, 10)
|
||||
self.main_layout.setSpacing(10)
|
||||
|
||||
# Tab widget
|
||||
self.tab_widget = QTabWidget()
|
||||
self.main_tab = QWidget()
|
||||
self.main_tab_layout = QVBoxLayout(self.main_tab)
|
||||
self.advanced_tab = QWidget()
|
||||
self.advanced_tab_layout = QVBoxLayout(self.advanced_tab)
|
||||
|
||||
self.tab_widget.addTab(self.main_tab, _("Main"))
|
||||
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
|
||||
|
||||
# Таблица настроек
|
||||
self.settings_table = QTableWidget()
|
||||
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.settings_table.setColumnCount(3)
|
||||
self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.settings_table.horizontalHeader().resizeSection(1, 100)
|
||||
self.settings_table.setWordWrap(True)
|
||||
self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
|
||||
self.main_tab_layout.addWidget(self.settings_table)
|
||||
|
||||
# Таблица Advanced
|
||||
self.advanced_table = QTableWidget()
|
||||
self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.advanced_table.setColumnCount(3)
|
||||
self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, 200)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
|
||||
self.advanced_tab_layout.addWidget(self.advanced_table)
|
||||
|
||||
self.main_layout.addWidget(self.tab_widget)
|
||||
|
||||
# Кнопки
|
||||
button_layout = QHBoxLayout()
|
||||
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply"))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel"))
|
||||
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button_layout.addWidget(self.apply_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
self.main_layout.addLayout(button_layout)
|
||||
|
||||
self.apply_button.clicked.connect(self.apply_changes)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load available toggles first, then current settings."""
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_list_db_finished)
|
||||
process.start(self.start_sh[0], ["cli", "--list-db"])
|
||||
|
||||
|
||||
def on_list_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --list-db output and extract available keys and system info."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.available_keys = set()
|
||||
self.blocked_keys = set()
|
||||
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
|
||||
lines = output.splitlines()
|
||||
self.numa_nodes = {}
|
||||
self.is_amd = False
|
||||
self.logical_core_options = []
|
||||
self.locale_options = []
|
||||
self.amd_vulkan_drivers = []
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
continue
|
||||
if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
|
||||
# System info
|
||||
k, v = line_stripped.split('=', 1)
|
||||
if k.startswith('NUMA_NODE_'):
|
||||
node_id = k[10:]
|
||||
self.numa_nodes[node_id] = v
|
||||
elif k == 'IS_AMD':
|
||||
self.is_amd = v.lower() == 'true'
|
||||
elif k == 'LOGICAL_CORE_OPTIONS':
|
||||
self.logical_core_options = v.split('!') if v else []
|
||||
elif k == 'LOCALE_LIST':
|
||||
self.locale_options = v.split('!') if v else []
|
||||
elif k == 'AMD_VULKAN_DRIVER_LIST':
|
||||
self.amd_vulkan_drivers = v.split('!') if v else []
|
||||
continue
|
||||
if line_stripped.startswith('PW_'):
|
||||
parts = line_stripped.split(maxsplit=1)
|
||||
key = parts[0]
|
||||
self.available_keys.add(key)
|
||||
if len(parts) > 1 and 'blocked' in parts[1]:
|
||||
self.blocked_keys.add(key)
|
||||
|
||||
# Показываем только пересечение
|
||||
self.available_keys &= set(self.toggle_settings.keys())
|
||||
|
||||
# Загружаем текущие настройки
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_show_ppdb_finished)
|
||||
process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
"""Handle --show-ppdb output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
# Fallback to defaults if load fails
|
||||
for key in self.toggle_settings:
|
||||
self.current_settings[key] = '0'
|
||||
for adv_key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
|
||||
'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
|
||||
'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
|
||||
'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
|
||||
'PW_CPU_NUMA_NODE_INDEX']:
|
||||
self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else ''
|
||||
else:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip()
|
||||
self.current_settings = {}
|
||||
for line in output.split('\n'):
|
||||
line_stripped = line.strip()
|
||||
if '=' in line_stripped:
|
||||
# Parse all KEY=VALUE lines, not just specific prefixes, to catch more
|
||||
try:
|
||||
key, val = line_stripped.split('=', 1)
|
||||
if key in self.toggle_settings or key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
|
||||
'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
|
||||
'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
|
||||
'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
|
||||
'PW_CPU_NUMA_NODE_INDEX', 'PW_TASKSET_SLR']:
|
||||
self.current_settings[key] = val
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Force blocked settings to '0'
|
||||
for key in self.blocked_keys:
|
||||
self.current_settings[key] = '0'
|
||||
|
||||
self.original_values = self.current_settings.copy()
|
||||
for key in set(self.toggle_settings.keys()):
|
||||
self.original_values.setdefault(key, '0')
|
||||
|
||||
self.populate_table()
|
||||
self.populate_advanced()
|
||||
|
||||
def populate_table(self):
|
||||
"""Populate the table with settings that are available in both lists."""
|
||||
self.settings_table.setRowCount(0)
|
||||
self.value_widgets.clear()
|
||||
self.settings_table.verticalHeader().setVisible(False)
|
||||
|
||||
visible_keys = sorted(self.available_keys) if self.available_keys else sorted(self.toggle_settings.keys())
|
||||
|
||||
for toggle in visible_keys:
|
||||
description = self.toggle_settings.get(toggle)
|
||||
if not description:
|
||||
continue
|
||||
|
||||
row = self.settings_table.rowCount()
|
||||
self.settings_table.insertRow(row)
|
||||
|
||||
name_item = QTableWidgetItem(toggle)
|
||||
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
current_val = self.current_settings.get(toggle, '0')
|
||||
is_blocked = toggle in self.blocked_keys
|
||||
checkbox = QTableWidgetItem()
|
||||
checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
|
||||
checkbox.setCheckState(check_state)
|
||||
checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if is_blocked:
|
||||
checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
|
||||
checkbox.setBackground(QColor(240, 240, 240))
|
||||
name_item.setForeground(QColor(128, 128, 128))
|
||||
self.settings_table.setItem(row, 1, checkbox)
|
||||
|
||||
desc_item = QTableWidgetItem(description)
|
||||
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
desc_item.setToolTip(description)
|
||||
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
if is_blocked:
|
||||
desc_item.setForeground(QColor(128, 128, 128))
|
||||
self.settings_table.setItem(row, 2, desc_item)
|
||||
|
||||
self.settings_table.setItem(row, 0, name_item)
|
||||
self.value_widgets[(row, 1)] = checkbox
|
||||
|
||||
self.settings_table.resizeRowsToContents()
|
||||
if self.settings_table.rowCount() > 0:
|
||||
self.settings_table.setCurrentCell(0, 0)
|
||||
self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def populate_advanced(self):
|
||||
"""Populate the advanced tab with table format."""
|
||||
self.advanced_table.setRowCount(0)
|
||||
self.advanced_widgets.clear()
|
||||
self.original_display_values = {}
|
||||
self.advanced_table.verticalHeader().setVisible(False)
|
||||
|
||||
current = self.current_settings
|
||||
disabled_text = _('disabled')
|
||||
|
||||
# Define advanced settings configuration
|
||||
advanced_settings = []
|
||||
|
||||
# 1. Windows version
|
||||
advanced_settings.append({
|
||||
'key': 'PW_WINDOWS_VER',
|
||||
'name': _("Windows version"),
|
||||
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
|
||||
'type': 'combo',
|
||||
'options': ['11', '10', '7', 'XP'],
|
||||
'default': '10'
|
||||
})
|
||||
|
||||
# 2. Forced to use/disable libraries
|
||||
advanced_settings.append({
|
||||
'key': 'WINEDLLOVERRIDES',
|
||||
'name': _("DLL Overrides"),
|
||||
'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
|
||||
'type': 'text',
|
||||
'default': ''
|
||||
})
|
||||
|
||||
# 3. Launch arguments
|
||||
advanced_settings.append({
|
||||
'key': 'LAUNCH_PARAMETERS',
|
||||
'name': _("Launch Arguments"),
|
||||
'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
|
||||
'type': 'text',
|
||||
'default': ''
|
||||
})
|
||||
|
||||
# 4. CPU cores limit
|
||||
advanced_settings.append({
|
||||
'key': 'PW_WINE_CPU_TOPOLOGY',
|
||||
'name': _("CPU Cores Limit"),
|
||||
'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text] + self.logical_core_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 5. OpenGL version
|
||||
advanced_settings.append({
|
||||
'key': 'PW_MESA_GL_VERSION_OVERRIDE',
|
||||
'name': _("OpenGL Version"),
|
||||
'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 6. VKD3D feature level
|
||||
advanced_settings.append({
|
||||
'key': 'PW_VKD3D_FEATURE_LEVEL',
|
||||
'name': _("VKD3D Feature Level"),
|
||||
'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 7. Locale
|
||||
advanced_settings.append({
|
||||
'key': 'PW_LOCALE_SELECT',
|
||||
'name': _("Locale"),
|
||||
'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text] + self.locale_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 8. Present mode
|
||||
advanced_settings.append({
|
||||
'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
|
||||
'name': _("Window Mode"),
|
||||
'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 9. AMD Vulkan (always show, block if not applicable)
|
||||
amd_options = [disabled_text] + self.amd_vulkan_drivers if self.is_amd and self.amd_vulkan_drivers else [disabled_text]
|
||||
advanced_settings.append({
|
||||
'key': 'PW_AMD_VULKAN_USE',
|
||||
'name': _("AMD Vulkan Driver"),
|
||||
'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
|
||||
'type': 'combo',
|
||||
'options': amd_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 10. NUMA node (always show if numa_nodes exist, block if <=1)
|
||||
numa_ids = sorted(self.numa_nodes.keys())
|
||||
numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
|
||||
advanced_settings.append({
|
||||
'key': 'PW_CPU_NUMA_NODE_INDEX',
|
||||
'name': _("NUMA Node"),
|
||||
'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
|
||||
'type': 'combo',
|
||||
'options': numa_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# Populate table
|
||||
for setting in advanced_settings:
|
||||
row = self.advanced_table.rowCount()
|
||||
self.advanced_table.insertRow(row)
|
||||
|
||||
# Name column
|
||||
name_item = QTableWidgetItem(setting['name'])
|
||||
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
self.advanced_table.setItem(row, 0, name_item)
|
||||
|
||||
# Value column (widget)
|
||||
if setting['type'] == 'combo':
|
||||
combo = QComboBox()
|
||||
combo.addItems(setting['options'])
|
||||
|
||||
# Get current value
|
||||
current_raw = current.get(setting['key'], setting['default'])
|
||||
if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
|
||||
current_val = disabled_text if current_raw == 'disabled' else (current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw)
|
||||
elif setting['key'] == 'PW_AMD_VULKAN_USE':
|
||||
current_val = disabled_text if not current_raw or current_raw == '' else current_raw
|
||||
else:
|
||||
current_val = disabled_text if current_raw == 'disabled' else current_raw
|
||||
|
||||
if current_val not in setting['options']:
|
||||
combo.addItem(current_val)
|
||||
combo.setCurrentText(current_val)
|
||||
|
||||
# Block if only disabled option
|
||||
if len(setting['options']) == 1:
|
||||
combo.setEnabled(False)
|
||||
|
||||
self.advanced_table.setCellWidget(row, 1, combo)
|
||||
self.advanced_widgets[setting['key']] = combo
|
||||
self.original_display_values[setting['key']] = current_val
|
||||
|
||||
elif setting['type'] == 'text':
|
||||
text_edit = QTextEdit()
|
||||
current_val = current.get(setting['key'], setting['default'])
|
||||
text_edit.setPlainText(current_val)
|
||||
|
||||
self.advanced_table.setCellWidget(row, 1, text_edit)
|
||||
self.advanced_widgets[setting['key']] = text_edit
|
||||
self.original_display_values[setting['key']] = current_val
|
||||
|
||||
# Description column
|
||||
desc_item = QTableWidgetItem(setting['description'])
|
||||
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
desc_item.setToolTip(setting['description'])
|
||||
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
self.advanced_table.setItem(row, 2, desc_item)
|
||||
|
||||
self.advanced_table.resizeRowsToContents()
|
||||
if self.advanced_table.rowCount() > 0:
|
||||
self.advanced_table.setCurrentCell(0, 0)
|
||||
|
||||
def apply_changes(self):
|
||||
"""Apply changes by collecting diffs from both main and advanced tabs."""
|
||||
changes = []
|
||||
|
||||
# --- 1. Обычные (toggle) настройки ---
|
||||
for key, orig_val in self.original_values.items():
|
||||
if key in self.blocked_keys:
|
||||
continue # Skip blocked keys
|
||||
row = -1
|
||||
for r in range(self.settings_table.rowCount()):
|
||||
item0 = self.settings_table.item(r, 0)
|
||||
if item0 and item0.text() == key:
|
||||
row = r
|
||||
break
|
||||
if row == -1:
|
||||
continue
|
||||
|
||||
item = self.settings_table.item(row, 1)
|
||||
if not item:
|
||||
continue
|
||||
|
||||
new_val = '1' if item.checkState() == Qt.CheckState.Checked else '0'
|
||||
if new_val != orig_val:
|
||||
changes.append(f"{key}={new_val}")
|
||||
|
||||
# --- 2. Advanced настройки ---
|
||||
for key, widget in self.advanced_widgets.items():
|
||||
orig_val = self.original_display_values.get(key, '')
|
||||
if isinstance(widget, QComboBox):
|
||||
new_val = widget.currentText()
|
||||
# приведение disabled к 'disabled'
|
||||
if new_val.lower() == _('disabled').lower():
|
||||
new_val = 'disabled'
|
||||
elif isinstance(widget, QTextEdit):
|
||||
new_val = widget.toPlainText().strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
if new_val != orig_val:
|
||||
changes.append(f"{key}={new_val}")
|
||||
|
||||
# --- 3. Проверка на изменения ---
|
||||
if not changes:
|
||||
QMessageBox.information(self, _("Info"), _("No changes to apply."))
|
||||
return
|
||||
|
||||
# --- 4. Запуск процесса сохранения ---
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_edit_db_finished)
|
||||
args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
process.start(self.start_sh[0], args)
|
||||
self.apply_button.setEnabled(False)
|
||||
|
||||
def on_edit_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --edit-db output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.apply_button.setEnabled(True)
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error(f"Failed to apply changes: {error_output}")
|
||||
else:
|
||||
self.load_current_settings()
|
||||
QMessageBox.information(self, _("Success"), _("Settings updated successfully."))
|
||||
|
||||
def closeEvent(self, event):
|
||||
super().closeEvent(event)
|
||||
|
||||
def reject(self):
|
||||
super().reject()
|
||||
|
||||
@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
|
||||
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.config_utils import get_portproton_location, get_portproton_start_command
|
||||
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, call_steam_api
|
||||
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
|
||||
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
|
||||
wrapper = get_portproton_start_command()
|
||||
|
||||
# Create launch script
|
||||
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
|
||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||
@@ -404,6 +404,13 @@ class GameCard(QFrame):
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'game_library_manager'):
|
||||
QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
|
||||
break
|
||||
parent = parent.parent()
|
||||
|
||||
def toggle_favorite(self):
|
||||
favorites = read_favorites()
|
||||
if self.is_favorite:
|
||||
|
||||
@@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol):
|
||||
# Required attributes
|
||||
searchEdit: CustomLineEdit
|
||||
_last_card_width: int
|
||||
card_width: int
|
||||
current_hovered_card: GameCard | None
|
||||
current_focused_card: GameCard | None
|
||||
gamesListWidget: QWidget | None
|
||||
@@ -128,6 +129,8 @@ class GameLibraryManager:
|
||||
self.card_width = self.sizeSlider.value()
|
||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||
save_card_size(self.card_width)
|
||||
self.main_window.card_width = self.card_width
|
||||
self.main_window._last_card_width = self.card_width
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
|
||||
@@ -83,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки URL {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
# SGDB ссылки содержат уникальный хеш в названии — используем как имя
|
||||
local_path = os.path.join(image_folder, filename)
|
||||
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
def on_downloaded(result: str | None):
|
||||
pixmap = QPixmap()
|
||||
if result and os.path.exists(result):
|
||||
pixmap.load(result)
|
||||
if pixmap.isNull():
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
|
||||
|
||||
if cover and cover.startswith(("http://", "https://")):
|
||||
try:
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg")
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from typing import Protocol, cast
|
||||
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
|
||||
from enum import Enum
|
||||
from pyudev import Context, Monitor, MonitorObserver, Device
|
||||
from pyudev import Context, Monitor, Device, Devices
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem
|
||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||
from PySide6.QtGui import QKeyEvent, QMouseEvent
|
||||
@@ -76,6 +76,7 @@ class InputManager(QObject):
|
||||
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
|
||||
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)
|
||||
gamepad_hotplug = Signal(str) # 'add' or 'remove'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1436,74 +1437,262 @@ class InputManager(QObject):
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def init_gamepad(self) -> None:
|
||||
self.udev_context = Context()
|
||||
self.Devices = Devices
|
||||
self.monitor_ready = False
|
||||
self.monitor_event = threading.Event()
|
||||
|
||||
# Подключаем сигнал hotplug к обработчику в главном потоке
|
||||
self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
|
||||
|
||||
# Debounce timer для отложенной проверки геймпада (в главном потоке Qt)
|
||||
self.gamepad_check_timer = QTimer()
|
||||
self.gamepad_check_timer.setSingleShot(True)
|
||||
self.gamepad_check_timer.timeout.connect(self.check_gamepad)
|
||||
|
||||
# Первоначальная проверка
|
||||
self.check_gamepad()
|
||||
|
||||
# Запускаем udev monitor в отдельном потоке
|
||||
threading.Thread(target=self.run_udev_monitor, daemon=True).start()
|
||||
logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
|
||||
|
||||
def run_udev_monitor(self) -> None:
|
||||
"""
|
||||
Безопасный неблокирующий udev monitor для геймпадов.
|
||||
Использует select.poll() вместо блокирующего monitor.poll().
|
||||
"""
|
||||
try:
|
||||
context = Context()
|
||||
monitor = Monitor.from_netlink(context)
|
||||
logger.info("Starting udev monitor...")
|
||||
monitor = Monitor.from_netlink(self.udev_context)
|
||||
monitor.filter_by(subsystem='input')
|
||||
observer = MonitorObserver(monitor, self.handle_udev_event)
|
||||
observer.start()
|
||||
|
||||
try:
|
||||
monitor.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start udev monitor: {e}")
|
||||
return
|
||||
|
||||
import select
|
||||
fd = monitor.fileno()
|
||||
poller = select.poll()
|
||||
poller.register(fd, select.POLLIN)
|
||||
|
||||
# Короткий дренаж событий при запуске (0.5 сек)
|
||||
drain_start = time.time()
|
||||
drained_count = 0
|
||||
while time.time() - drain_start < 0.5:
|
||||
events = poller.poll(100)
|
||||
if not events:
|
||||
continue
|
||||
try:
|
||||
_ = monitor.poll(timeout=0) # просто читаем, не обрабатываем
|
||||
drained_count += 1
|
||||
except Exception:
|
||||
break
|
||||
|
||||
self.monitor_ready = True
|
||||
self.monitor_event.set()
|
||||
logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
|
||||
|
||||
# Основной цикл
|
||||
while self.running:
|
||||
time.sleep(1)
|
||||
events = poller.poll(1000) # 1 сек таймаут
|
||||
if not events:
|
||||
continue # просто ждём, не блокируем
|
||||
|
||||
try:
|
||||
device = monitor.poll(timeout=0)
|
||||
except Exception as e:
|
||||
logger.debug(f"Monitor poll failed: {e}")
|
||||
continue
|
||||
|
||||
if not device:
|
||||
continue
|
||||
|
||||
action = device.action
|
||||
if action and self._is_joystick_device(device):
|
||||
logger.info(f"Joystick hotplug event: {action} for {device.sys_name}")
|
||||
# отправляем сигнал в Qt-поток
|
||||
self.handle_udev_event(action, device)
|
||||
|
||||
logger.info("udev monitor stopped gracefully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in udev monitor: {e}", exc_info=True)
|
||||
|
||||
def _is_joystick_device(self, device: Device) -> bool:
|
||||
"""
|
||||
Быстрая проверка: является ли устройство джойстиком.
|
||||
Проверяет ID_INPUT_JOYSTICK из udev базы данных.
|
||||
"""
|
||||
try:
|
||||
# Проверяем свойство ID_INPUT_JOYSTICK
|
||||
if device.get('ID_INPUT_JOYSTICK') == '1':
|
||||
return True
|
||||
|
||||
# Дополнительно: проверяем родительские устройства
|
||||
# (некоторые контроллеры имеют свойство только у родителя)
|
||||
parent = device.parent
|
||||
if parent and parent.get('ID_INPUT_JOYSTICK') == '1':
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug(f"Error checking joystick device: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_udev_event(self, action: str, device: Device) -> None:
|
||||
"""
|
||||
Обработчик udev событий для джойстиков.
|
||||
Отправляет сигнал в главный поток Qt вместо прямого вызова QTimer.
|
||||
"""
|
||||
try:
|
||||
if action == 'add':
|
||||
time.sleep(0.1)
|
||||
self.check_gamepad()
|
||||
# Отправляем сигнал в главный поток Qt
|
||||
# QTimer будет запущен там безопасно
|
||||
logger.debug("Emitting gamepad add signal")
|
||||
self.gamepad_hotplug.emit('add')
|
||||
|
||||
elif action == 'remove' and self.gamepad:
|
||||
if not any(self.gamepad.path == path for path in list_devices()):
|
||||
logger.info("Gamepad disconnected")
|
||||
self.stop_rumble()
|
||||
self.gamepad = None
|
||||
if self.gamepad_thread:
|
||||
self.gamepad_thread.join()
|
||||
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
||||
self.toggle_fullscreen.emit(False)
|
||||
# Проверяем конкретно наш геймпад по пути устройства
|
||||
device_node = device.device_node # например, /dev/input/event3
|
||||
|
||||
if device_node and self.gamepad.path == device_node:
|
||||
logger.info(f"Connected gamepad disconnected: {device_node}")
|
||||
# Отправляем сигнал в главный поток
|
||||
self.gamepad_hotplug.emit('remove')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling udev event: {e}", exc_info=True)
|
||||
|
||||
|
||||
def _on_gamepad_hotplug(self, action: str) -> None:
|
||||
"""
|
||||
Обработчик сигнала hotplug, выполняется в главном потоке Qt.
|
||||
Безопасно работает с QTimer.
|
||||
"""
|
||||
try:
|
||||
if action == 'add':
|
||||
# Debounce: откладываем проверку на 200ms
|
||||
# Множественные события за короткое время объединяются в один вызов
|
||||
logger.debug("Scheduling gamepad check (debounced)")
|
||||
self.gamepad_check_timer.start(200)
|
||||
|
||||
elif action == 'remove':
|
||||
# Немедленная обработка отключения
|
||||
self.stop_rumble()
|
||||
self.gamepad = None
|
||||
|
||||
if self.gamepad_thread:
|
||||
self.gamepad_thread.join(timeout=2.0)
|
||||
|
||||
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
||||
self.toggle_fullscreen.emit(False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in hotplug handler: {e}", exc_info=True)
|
||||
|
||||
def check_gamepad(self) -> None:
|
||||
"""
|
||||
Проверка и подключение геймпада.
|
||||
Вызывается из главного потока Qt через QTimer (debounced).
|
||||
"""
|
||||
try:
|
||||
new_gamepad = self.find_gamepad()
|
||||
if new_gamepad and new_gamepad != self.gamepad:
|
||||
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
||||
|
||||
if new_gamepad:
|
||||
if not self.gamepad or new_gamepad.path != self.gamepad.path:
|
||||
logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
|
||||
self.stop_rumble()
|
||||
self.gamepad = new_gamepad
|
||||
|
||||
if self.gamepad_thread and self.gamepad_thread.is_alive():
|
||||
self.gamepad_thread.join(timeout=2.0)
|
||||
|
||||
def start_monitoring():
|
||||
# Ожидание готовности udev monitor без busy-wait
|
||||
if not self.monitor_event.wait(timeout=2.0):
|
||||
logger.warning("Timeout waiting for udev monitor readiness")
|
||||
self.monitor_gamepad()
|
||||
|
||||
self.gamepad_thread = threading.Thread(
|
||||
target=start_monitoring,
|
||||
daemon=True
|
||||
)
|
||||
self.gamepad_thread.start()
|
||||
|
||||
# Автоматический фуллскрин при подключении геймпада
|
||||
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
||||
self.toggle_fullscreen.emit(True)
|
||||
|
||||
elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()):
|
||||
logger.info("Gamepad no longer detected")
|
||||
self.stop_rumble()
|
||||
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()
|
||||
# Send signal for fullscreen mode only if:
|
||||
# 1. auto_fullscreen_gamepad is enabled
|
||||
# 2. fullscreen is not already enabled (to avoid conflict)
|
||||
self.gamepad = None
|
||||
|
||||
if self.gamepad_thread and self.gamepad_thread.is_alive():
|
||||
self.gamepad_thread.join(timeout=2.0)
|
||||
|
||||
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
||||
self.toggle_fullscreen.emit(True)
|
||||
self.toggle_fullscreen.emit(False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking gamepad: {e}", exc_info=True)
|
||||
|
||||
def find_gamepad(self) -> InputDevice | None:
|
||||
"""
|
||||
Находит первый доступный геймпад.
|
||||
Оптимизирован: предварительная фильтрация по capabilities перед udev-запросами.
|
||||
"""
|
||||
try:
|
||||
devices = [InputDevice(path) for path in list_devices()]
|
||||
|
||||
if not devices:
|
||||
return None
|
||||
|
||||
logger.debug(f"Checking {len(devices)} devices for gamepad...")
|
||||
|
||||
for device in devices:
|
||||
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2)
|
||||
# Skip ASRock LED controller (известная проблема)
|
||||
if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
|
||||
logger.debug(f"Skipping ASRock LED controller: {device.name}")
|
||||
continue
|
||||
caps = device.capabilities()
|
||||
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
|
||||
return device
|
||||
|
||||
# Предварительная фильтрация: проверяем capabilities
|
||||
# Джойстик должен иметь хотя бы оси (ABS) или кнопки (KEY)
|
||||
# Это избегает udev-запросов для явно не-джойстиков
|
||||
caps = device.capabilities(verbose=False)
|
||||
has_abs_axes = ecodes.EV_ABS in caps
|
||||
has_buttons = ecodes.EV_KEY in caps
|
||||
|
||||
if not (has_abs_axes or has_buttons):
|
||||
continue
|
||||
|
||||
# Только для потенциальных джойстиков делаем udev-запрос
|
||||
try:
|
||||
udev_device = self.Devices.from_device_file(
|
||||
self.udev_context,
|
||||
device.path
|
||||
)
|
||||
is_joystick = udev_device.get('ID_INPUT_JOYSTICK')
|
||||
|
||||
if is_joystick == '1':
|
||||
logger.info(f"Found gamepad: {device.name}")
|
||||
return device
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check udev properties for {device.path}: {e}")
|
||||
continue
|
||||
|
||||
logger.debug("No gamepad found")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding gamepad: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def monitor_gamepad(self) -> None:
|
||||
try:
|
||||
if not self.gamepad:
|
||||
@@ -1567,16 +1756,32 @@ class InputManager(QObject):
|
||||
self.gamepad = None
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Корректное завершение работы с геймпадом и udev монитором.
|
||||
"""
|
||||
try:
|
||||
# Флаг для остановки udev monitor loop
|
||||
self.running = False
|
||||
|
||||
# Останавливаем все таймеры
|
||||
if hasattr(self, 'gamepad_check_timer'):
|
||||
self.gamepad_check_timer.stop()
|
||||
self.dpad_timer.stop()
|
||||
self.nav_timer.stop()
|
||||
|
||||
# Очистка геймпада
|
||||
self.stop_rumble()
|
||||
|
||||
if self.gamepad_thread:
|
||||
self.gamepad_thread.join()
|
||||
self.gamepad_thread.join(timeout=2.0)
|
||||
|
||||
if self.gamepad:
|
||||
self.gamepad.close()
|
||||
|
||||
self.gamepad = None
|
||||
self.gamepad_type = GamepadType.UNKNOWN
|
||||
|
||||
logger.info("Gamepad cleanup completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-10-16 10:43+0500\n"
|
||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -624,6 +624,12 @@ msgstr ""
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-10-16 10:43+0500\n"
|
||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -624,6 +624,12 @@ msgstr ""
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-10-16 10:43+0500\n"
|
||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -622,6 +622,12 @@ msgstr ""
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-10-16 10:43+0500\n"
|
||||
"PO-Revision-Date: 2025-10-16 10:43+0500\n"
|
||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
|
||||
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
@@ -633,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr "Режим полноэкранного отображения приложения:"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Сворачивать в трей при закрытии"
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr "Режим закрытия приложения:"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import psutil
|
||||
import re
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.animations import DetailPageAnimations
|
||||
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
|
||||
@@ -29,7 +29,8 @@ from portprotonqt.config_utils import (
|
||||
read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
|
||||
save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
|
||||
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
|
||||
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type
|
||||
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
|
||||
read_auto_card_size, save_auto_card_size, get_portproton_start_command
|
||||
)
|
||||
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
|
||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||
@@ -39,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
|
||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
|
||||
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller)
|
||||
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider)
|
||||
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
|
||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
||||
from typing import cast
|
||||
@@ -63,6 +64,7 @@ class MainWindow(QMainWindow):
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
|
||||
self.card_width = read_card_size()
|
||||
self.auto_card_width = read_auto_card_size()
|
||||
self._last_card_width = self.card_width
|
||||
self.setWindowTitle(f"{app_name} {version}")
|
||||
self.setMinimumSize(800, 600)
|
||||
@@ -72,6 +74,7 @@ class MainWindow(QMainWindow):
|
||||
self.target_exe = None
|
||||
self.current_running_button = None
|
||||
self.portproton_location = get_portproton_location()
|
||||
self.start_sh = get_portproton_start_command()
|
||||
|
||||
self.game_library_manager = GameLibraryManager(self, self.theme, None)
|
||||
|
||||
@@ -456,11 +459,11 @@ class MainWindow(QMainWindow):
|
||||
self.current_install_script = script_name
|
||||
self.seen_progress = False
|
||||
self.current_percent = 0.0
|
||||
start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else ""
|
||||
if not os.path.exists(start_sh):
|
||||
start_sh = self.start_sh
|
||||
if not start_sh:
|
||||
self.installing = False
|
||||
return
|
||||
cmd = [start_sh, "cli", "--autoinstall", script_name]
|
||||
cmd = start_sh + ["cli", "--autoinstall", script_name]
|
||||
self.install_process = QProcess(self)
|
||||
self.install_process.finished.connect(self.on_install_finished)
|
||||
self.install_process.errorOccurred.connect(self.on_install_error)
|
||||
@@ -1100,8 +1103,7 @@ class MainWindow(QMainWindow):
|
||||
autoInstallPage = QWidget()
|
||||
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
autoInstallLayout = QVBoxLayout(autoInstallPage)
|
||||
autoInstallLayout.setContentsMargins(20, 0, 20, 0)
|
||||
autoInstallLayout.setSpacing(0)
|
||||
autoInstallLayout.setSpacing(15)
|
||||
|
||||
# Верхняя панель с заголовком и поиском
|
||||
headerWidget = QWidget()
|
||||
@@ -1150,6 +1152,25 @@ class MainWindow(QMainWindow):
|
||||
|
||||
autoInstallLayout.addWidget(self.autoInstallScrollArea)
|
||||
|
||||
# Slider for card size
|
||||
sliderLayout = QHBoxLayout()
|
||||
sliderLayout.setSpacing(0)
|
||||
sliderLayout.setContentsMargins(0, 0, 0, 0)
|
||||
sliderLayout.addStretch()
|
||||
|
||||
self.auto_size_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.auto_size_slider.setMinimum(200)
|
||||
self.auto_size_slider.setMaximum(250)
|
||||
self.auto_size_slider.setValue(self.auto_card_width)
|
||||
self.auto_size_slider.setTickInterval(10)
|
||||
self.auto_size_slider.setFixedWidth(150)
|
||||
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
|
||||
self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
|
||||
self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released)
|
||||
sliderLayout.addWidget(self.auto_size_slider)
|
||||
|
||||
autoInstallLayout.addLayout(sliderLayout)
|
||||
|
||||
# Хранение карточек
|
||||
self.autoInstallGameCards = {}
|
||||
self.allAutoInstallCards = []
|
||||
@@ -1159,7 +1180,7 @@ class MainWindow(QMainWindow):
|
||||
if exe_name in self.autoInstallGameCards and local_path:
|
||||
card = self.autoInstallGameCards[exe_name]
|
||||
card.cover_path = local_path
|
||||
load_pixmap_async(local_path, self.card_width, int(self.card_width * 1.5), card.on_cover_loaded)
|
||||
load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded)
|
||||
|
||||
# Загрузка игр
|
||||
def on_autoinstall_games_loaded(games: list[tuple]):
|
||||
@@ -1195,7 +1216,7 @@ class MainWindow(QMainWindow):
|
||||
None, None, None, game_source,
|
||||
select_callback=select_callback,
|
||||
theme=self.theme,
|
||||
card_width=self.card_width,
|
||||
card_width=self.auto_card_width,
|
||||
parent=self.autoInstallContainer,
|
||||
)
|
||||
|
||||
@@ -1233,10 +1254,30 @@ class MainWindow(QMainWindow):
|
||||
# Показываем прогресс
|
||||
self.autoInstallProgress.setVisible(True)
|
||||
self.autoInstallProgress.setRange(0, 0)
|
||||
self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded)
|
||||
|
||||
# Store the thread to prevent premature destruction
|
||||
self.autoInstallLoadThread = self.portproton_api.start_autoinstall_games_load(on_autoinstall_games_loaded)
|
||||
|
||||
# Optional: Clean up thread when finished (prevents leak)
|
||||
if self.autoInstallLoadThread:
|
||||
def on_thread_finished():
|
||||
self.autoInstallLoadThread = None # Release reference
|
||||
self.autoInstallLoadThread.finished.connect(on_thread_finished)
|
||||
|
||||
self.stackedWidget.addWidget(autoInstallPage)
|
||||
|
||||
def on_auto_slider_released(self):
|
||||
"""Handles auto-install slider release to update card size."""
|
||||
if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
|
||||
self.auto_card_width = self.auto_size_slider.value()
|
||||
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
|
||||
save_auto_card_size(self.auto_card_width)
|
||||
for card in self.allAutoInstallCards:
|
||||
card.update_card_size(self.auto_card_width)
|
||||
self.autoInstallContainerLayout.invalidate()
|
||||
self.autoInstallContainer.updateGeometry()
|
||||
self.autoInstallScrollArea.updateGeometry()
|
||||
|
||||
def filterAutoInstallGames(self):
|
||||
"""Filter auto install game cards based on search text."""
|
||||
search_text = self.autoInstallSearchLineEdit.text().lower().strip()
|
||||
@@ -1384,12 +1425,10 @@ class MainWindow(QMainWindow):
|
||||
prefix = self.prefixCombo.currentText()
|
||||
if not wine or not prefix:
|
||||
return
|
||||
if not self.portproton_location:
|
||||
if not self.portproton_location or not self.start_sh:
|
||||
return
|
||||
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
if not os.path.exists(start_sh):
|
||||
return
|
||||
cmd = [start_sh, "cli", cli_arg, wine, prefix]
|
||||
start_sh = self.start_sh
|
||||
cmd = start_sh + ["cli", cli_arg, wine, prefix]
|
||||
|
||||
# Показываем прогресс-бар перед запуском
|
||||
self.wine_progress_bar.setVisible(True)
|
||||
@@ -1468,12 +1507,13 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
|
||||
|
||||
def clear_prefix(self):
|
||||
"""Очистка префикса (позже удалить)."""
|
||||
"""Очищает префикс"""
|
||||
selected_prefix = self.prefixCombo.currentText()
|
||||
selected_wine = self.wineCombo.currentText()
|
||||
|
||||
if not selected_prefix or not selected_wine:
|
||||
return
|
||||
if not self.portproton_location:
|
||||
if not self.portproton_location or not self.start_sh:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
@@ -1486,98 +1526,35 @@ class MainWindow(QMainWindow):
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
|
||||
if not os.path.exists(prefix_dir):
|
||||
start_sh = self.start_sh
|
||||
|
||||
self.wine_progress_bar.setVisible(True)
|
||||
self.update_status_message.emit(_("Clearing prefix..."), 0)
|
||||
|
||||
self.clear_process = QProcess(self)
|
||||
self.clear_process.finished.connect(lambda exitCode, exitStatus: self._on_clear_prefix_finished(exitCode))
|
||||
self.clear_process.errorOccurred.connect(lambda error: self._on_clear_prefix_error(error))
|
||||
cmd = start_sh + ["cli", "--clear_pfx", selected_wine, selected_prefix]
|
||||
self.clear_process.start(cmd[0], cmd[1:])
|
||||
|
||||
if not self.clear_process.waitForStarted(5000):
|
||||
self.wine_progress_bar.setVisible(False)
|
||||
self.update_status_message.emit("", 0)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to start prefix clear process."))
|
||||
return
|
||||
|
||||
success = True
|
||||
errors = []
|
||||
|
||||
# Удаление файлов
|
||||
files_to_remove = [
|
||||
os.path.join(prefix_dir, "*.dot*"),
|
||||
os.path.join(prefix_dir, "*.prog*"),
|
||||
os.path.join(prefix_dir, ".wine_ver"),
|
||||
os.path.join(prefix_dir, "system.reg"),
|
||||
os.path.join(prefix_dir, "user.reg"),
|
||||
os.path.join(prefix_dir, "userdef.reg"),
|
||||
os.path.join(prefix_dir, "winetricks.log"),
|
||||
os.path.join(prefix_dir, ".update-timestamp"),
|
||||
os.path.join(prefix_dir, "drive_c", ".windows-serial"),
|
||||
]
|
||||
|
||||
import glob
|
||||
for pattern in files_to_remove:
|
||||
if "*" in pattern: # Глобальный паттерн
|
||||
matches = glob.glob(pattern)
|
||||
for file_path in matches:
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
success = False
|
||||
errors.append(str(e))
|
||||
else: # Конкретный файл
|
||||
try:
|
||||
if os.path.exists(pattern):
|
||||
os.remove(pattern)
|
||||
except Exception as e:
|
||||
success = False
|
||||
errors.append(str(e))
|
||||
|
||||
# Удаление директорий
|
||||
dirs_to_remove = [
|
||||
os.path.join(prefix_dir, "drive_c", "windows"),
|
||||
os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"),
|
||||
os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"),
|
||||
os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"),
|
||||
os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"),
|
||||
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"),
|
||||
os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"),
|
||||
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"),
|
||||
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"),
|
||||
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"),
|
||||
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"),
|
||||
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"),
|
||||
]
|
||||
|
||||
import shutil
|
||||
for dir_path in dirs_to_remove:
|
||||
try:
|
||||
if os.path.exists(dir_path):
|
||||
shutil.rmtree(dir_path)
|
||||
except Exception as e:
|
||||
success = False
|
||||
errors.append(str(e))
|
||||
|
||||
tmp_path = os.path.join(self.portproton_location, "data", "tmp")
|
||||
if os.path.exists(tmp_path):
|
||||
import glob
|
||||
bin_files = glob.glob(os.path.join(tmp_path, "*.bin"))
|
||||
foz_files = glob.glob(os.path.join(tmp_path, "*.foz"))
|
||||
for file_path in bin_files + foz_files:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
success = False
|
||||
errors.append(str(e))
|
||||
|
||||
if success:
|
||||
QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix))
|
||||
def _on_clear_prefix_finished(self, exitCode):
|
||||
self.wine_progress_bar.setVisible(False)
|
||||
self.update_status_message.emit("", 0)
|
||||
if exitCode == 0:
|
||||
QMessageBox.information(self, _("Success"), _("Prefix cleared successfully."))
|
||||
else:
|
||||
error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5]))
|
||||
QMessageBox.warning(self, _("Warning"), error_msg)
|
||||
QMessageBox.warning(self, _("Error"), _("Prefix clear failed with exit code {}.").format(exitCode))
|
||||
|
||||
def _on_clear_prefix_error(self, error):
|
||||
self.wine_progress_bar.setVisible(False)
|
||||
self.update_status_message.emit("", 0)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to run clear prefix command: {}").format(error))
|
||||
|
||||
def create_prefix_backup(self):
|
||||
selected_prefix = self.prefixCombo.currentText()
|
||||
@@ -1589,14 +1566,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _perform_backup(self, backup_dir, prefix_name):
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
if not self.portproton_location:
|
||||
return
|
||||
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
if not os.path.exists(start_sh):
|
||||
if not self.portproton_location or not self.start_sh:
|
||||
return
|
||||
start_sh = self.start_sh
|
||||
self.backup_process = QProcess(self)
|
||||
self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode))
|
||||
cmd = [start_sh, "--backup-prefix", prefix_name, backup_dir]
|
||||
cmd = start_sh + ["--backup-prefix", prefix_name, backup_dir]
|
||||
self.backup_process.start(cmd[0], cmd[1:])
|
||||
if not self.backup_process.waitForStarted():
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
|
||||
@@ -1609,14 +1584,12 @@ class MainWindow(QMainWindow):
|
||||
def _perform_restore(self, file_path):
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
return
|
||||
if not self.portproton_location:
|
||||
return
|
||||
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
if not os.path.exists(start_sh):
|
||||
if not self.portproton_location or not self.start_sh:
|
||||
return
|
||||
start_sh = self.start_sh
|
||||
self.restore_process = QProcess(self)
|
||||
self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode))
|
||||
cmd = [start_sh, "--restore-prefix", file_path]
|
||||
cmd = start_sh + ["--restore-prefix", file_path]
|
||||
self.restore_process.start(cmd[0], cmd[1:])
|
||||
if not self.restore_process.waitForStarted():
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
|
||||
@@ -1860,7 +1833,19 @@ class MainWindow(QMainWindow):
|
||||
self.fullscreenCheckBox.setChecked(current_fullscreen)
|
||||
formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
|
||||
|
||||
# 7. Automatic fullscreen on gamepad connection
|
||||
# 7. Minimize to tray setting
|
||||
self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close"))
|
||||
self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
||||
self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.minimizeToTrayTitle = QLabel(_("Application Close Mode:"))
|
||||
self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
current_minimize_to_tray = read_minimize_to_tray()
|
||||
self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray)
|
||||
self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked))
|
||||
formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox)
|
||||
|
||||
# 8. Automatic fullscreen on gamepad connection
|
||||
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
|
||||
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
||||
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -1872,7 +1857,7 @@ class MainWindow(QMainWindow):
|
||||
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
|
||||
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
|
||||
|
||||
# 8. Gamepad haptic feedback config
|
||||
# 9. Gamepad haptic feedback config
|
||||
self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
|
||||
self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
||||
@@ -2274,6 +2259,14 @@ class MainWindow(QMainWindow):
|
||||
def darkenColor(self, color, factor=200):
|
||||
return color.darker(factor)
|
||||
|
||||
def open_exe_settings(self, exe_path):
|
||||
"""Open the ExeSettingsDialog for the given executable."""
|
||||
if not os.path.exists(exe_path):
|
||||
QMessageBox.warning(self, _("Error"), _("Executable not found: {0}").format(exe_path))
|
||||
return
|
||||
dialog = ExeSettingsDialog(self, self.theme, exe_path)
|
||||
dialog.exec()
|
||||
|
||||
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 = {}
|
||||
@@ -2576,8 +2569,6 @@ class MainWindow(QMainWindow):
|
||||
|
||||
clear_layout(hltbLayout)
|
||||
|
||||
|
||||
|
||||
has_data = False
|
||||
|
||||
if main_story_time is not None:
|
||||
@@ -2661,6 +2652,14 @@ class MainWindow(QMainWindow):
|
||||
playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton))
|
||||
detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# Settings button
|
||||
settings_icon = self.theme_manager.get_icon("settings")
|
||||
settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
|
||||
settings_button.setFixedSize(120, 40)
|
||||
settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
|
||||
settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check))
|
||||
detailsLayout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
contentFrameLayout.addWidget(detailsWidget)
|
||||
mainLayout.addStretch()
|
||||
|
||||
@@ -2883,10 +2882,7 @@ class MainWindow(QMainWindow):
|
||||
env_vars = os.environ.copy()
|
||||
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
|
||||
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
if self.portproton_location is not None and ".var" not in self.portproton_location:
|
||||
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
wrapper = start_sh
|
||||
wrapper = self.start_sh or ""
|
||||
|
||||
cmd = [wrapper, game_exe]
|
||||
|
||||
@@ -2980,13 +2976,6 @@ class MainWindow(QMainWindow):
|
||||
exe_name = os.path.splitext(current_exe)[0]
|
||||
env_vars = os.environ.copy()
|
||||
|
||||
if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]:
|
||||
env_vars['START_FROM_STEAM'] = '1'
|
||||
env_vars['PROCESS_LOG'] = '1'
|
||||
elif entry_exec_split[0] == "flatpak":
|
||||
env_vars['START_FROM_STEAM'] = '1'
|
||||
env_vars['PROCESS_LOG'] = '1'
|
||||
|
||||
# Запускаем игру
|
||||
try:
|
||||
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
|
||||
@@ -3008,57 +2997,77 @@ class MainWindow(QMainWindow):
|
||||
logger.error(f"Failed to launch game {exe_name}: {e}")
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
|
||||
if hasattr(self, 'is_exiting') and self.is_exiting:
|
||||
# Принудительное закрытие: завершаем процессы и приложение
|
||||
for proc in self.game_processes:
|
||||
try:
|
||||
parent = psutil.Process(proc.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
logger.debug(f"Terminating child process {child.pid}")
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
logger.debug(f"Child process {child.pid} already terminated")
|
||||
psutil.wait_procs(children, timeout=5)
|
||||
for child in children:
|
||||
if child.is_running():
|
||||
logger.debug(f"Killing child process {child.pid}")
|
||||
child.kill()
|
||||
logger.debug(f"Terminating process group {proc.pid}")
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except (psutil.NoSuchProcess, ProcessLookupError) as e:
|
||||
logger.debug(f"Process {proc.pid} already terminated: {e}")
|
||||
"""Обработчик закрытия окна: проверяет настройку minimize_to_tray.
|
||||
Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
|
||||
"""
|
||||
minimize_to_tray = read_minimize_to_tray()
|
||||
|
||||
self.game_processes = [] # Очищаем список процессов
|
||||
|
||||
# Очищаем таймеры
|
||||
if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive():
|
||||
self.games_load_timer.stop()
|
||||
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive():
|
||||
self.settingsDebounceTimer.stop()
|
||||
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive():
|
||||
self.searchDebounceTimer.stop()
|
||||
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive():
|
||||
self.checkProcessTimer.stop()
|
||||
self.checkProcessTimer.deleteLater()
|
||||
self.checkProcessTimer = None
|
||||
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
|
||||
self.wine_monitor_timer.stop()
|
||||
self.wine_monitor_timer.deleteLater()
|
||||
self.wine_monitor_timer = None
|
||||
|
||||
# Сохраняем настройки окна
|
||||
if not read_fullscreen_config():
|
||||
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
|
||||
save_window_geometry(self.width(), self.height())
|
||||
save_card_size(self.card_width)
|
||||
|
||||
event.accept()
|
||||
else:
|
||||
# Сворачиваем в трей вместо закрытия
|
||||
self.hide()
|
||||
if minimize_to_tray:
|
||||
# Просто сворачиваем в трей
|
||||
event.ignore()
|
||||
self.hide()
|
||||
return
|
||||
|
||||
# Полное закрытие приложения
|
||||
self.is_exiting = True
|
||||
event.accept()
|
||||
|
||||
# Скрываем и удаляем иконку трея
|
||||
if hasattr(self, "tray_manager") and self.tray_manager.tray_icon:
|
||||
self.tray_manager.tray_icon.hide()
|
||||
self.tray_manager.tray_icon.deleteLater()
|
||||
|
||||
# Сохраняем размеры карточек
|
||||
save_card_size(self.card_width)
|
||||
save_auto_card_size(self.auto_card_width)
|
||||
|
||||
# Сохраняем размеры окна (если не в полноэкранном режиме)
|
||||
if not read_fullscreen_config():
|
||||
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
|
||||
save_window_geometry(self.width(), self.height())
|
||||
|
||||
# Завершаем все игровые процессы
|
||||
for proc in getattr(self, "game_processes", []):
|
||||
try:
|
||||
parent = psutil.Process(proc.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
logger.debug(f"Terminating child process {child.pid}")
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
logger.debug(f"Child process {child.pid} already terminated")
|
||||
|
||||
psutil.wait_procs(children, timeout=5)
|
||||
for child in children:
|
||||
if child.is_running():
|
||||
logger.debug(f"Killing child process {child.pid}")
|
||||
child.kill()
|
||||
|
||||
logger.debug(f"Terminating process group {proc.pid}")
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
|
||||
except (psutil.NoSuchProcess, ProcessLookupError) as e:
|
||||
logger.debug(f"Process {getattr(proc, 'pid', '?')} already terminated: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to terminate process {getattr(proc, 'pid', '?')}: {e}")
|
||||
|
||||
self.game_processes = []
|
||||
|
||||
# Универсальная остановка и удаление таймеров
|
||||
timers = [
|
||||
"games_load_timer",
|
||||
"settingsDebounceTimer",
|
||||
"searchDebounceTimer",
|
||||
"checkProcessTimer",
|
||||
"wine_monitor_timer",
|
||||
]
|
||||
|
||||
for tname in timers:
|
||||
timer = getattr(self, tname, None)
|
||||
if timer and timer.isActive():
|
||||
timer.stop()
|
||||
if timer:
|
||||
timer.deleteLater()
|
||||
setattr(self, tname, None)
|
||||
|
||||
@@ -6,13 +6,16 @@ import urllib.parse
|
||||
import time
|
||||
import glob
|
||||
import re
|
||||
import hashlib
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
|
||||
logger = get_logger(__name__)
|
||||
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
||||
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
|
||||
|
||||
def normalize_name(s):
|
||||
"""
|
||||
@@ -59,6 +62,7 @@ class PortProtonAPI:
|
||||
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
|
||||
self._topics_data = None
|
||||
self._autoinstall_cache = None # New: In-memory cache
|
||||
|
||||
def _get_game_dir(self, exe_name: str) -> str:
|
||||
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
||||
@@ -231,67 +235,139 @@ class PortProtonAPI:
|
||||
logger.error(f"Failed to parse {file_path}: {e}")
|
||||
return None, None
|
||||
|
||||
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
"""Load auto-install games with user/builtin covers (no async download here)."""
|
||||
games = []
|
||||
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else ""
|
||||
def _compute_scripts_signature(self, auto_dir: str) -> str:
|
||||
"""Compute a hash-based signature of the autoinstall scripts to detect changes."""
|
||||
if not os.path.exists(auto_dir):
|
||||
callback(games)
|
||||
return
|
||||
|
||||
return ""
|
||||
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||
if not scripts:
|
||||
callback(games)
|
||||
return
|
||||
# Simple hash: concatenate sorted filenames and hash
|
||||
filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
|
||||
return hashlib.md5(filenames_str.encode()).hexdigest()
|
||||
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
|
||||
os.makedirs(base_autoinstall_dir, exist_ok=True)
|
||||
def _load_autoinstall_cache(self):
|
||||
"""Load cached autoinstall games if fresh and scripts unchanged."""
|
||||
if self._autoinstall_cache is not None:
|
||||
return self._autoinstall_cache
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
|
||||
if os.path.exists(cache_file):
|
||||
try:
|
||||
mod_time = os.path.getmtime(cache_file)
|
||||
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
|
||||
with open(cache_file, "rb") as f:
|
||||
data = orjson.loads(f.read())
|
||||
# Check signature
|
||||
cached_signature = data.get("scripts_signature", "")
|
||||
current_signature = self._compute_scripts_signature(
|
||||
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
)
|
||||
if cached_signature != current_signature:
|
||||
logger.info("Scripts signature mismatch; invalidating cache")
|
||||
return None
|
||||
self._autoinstall_cache = data["games"]
|
||||
logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
|
||||
return self._autoinstall_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load autoinstall cache: {e}")
|
||||
return None
|
||||
|
||||
for script_path in scripts:
|
||||
display_name, exe_name = self.parse_autoinstall_script(script_path)
|
||||
script_name = os.path.splitext(os.path.basename(script_path))[0]
|
||||
def _save_autoinstall_cache(self, games):
|
||||
"""Save parsed autoinstall games to cache with scripts signature."""
|
||||
try:
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
|
||||
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
scripts_signature = self._compute_scripts_signature(auto_dir)
|
||||
data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save autoinstall cache: {e}")
|
||||
|
||||
if not (display_name and exe_name):
|
||||
continue
|
||||
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
|
||||
"""Start loading auto-install games in a background thread. Returns the thread for management."""
|
||||
# Check cache first (sync, fast)
|
||||
cached_games = self._load_autoinstall_cache()
|
||||
if cached_games is not None:
|
||||
# Emit via callback immediately if cached
|
||||
QThread.msleep(0) # Yield to Qt event loop
|
||||
callback(cached_games)
|
||||
return None # No thread needed
|
||||
|
||||
exe_name = os.path.splitext(exe_name)[0] # Без .exe
|
||||
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
|
||||
os.makedirs(user_game_folder, exist_ok=True)
|
||||
# No cache: Start background thread
|
||||
class AutoinstallWorker(QThread):
|
||||
finished = Signal(list)
|
||||
api: "PortProtonAPI"
|
||||
portproton_location: str | None
|
||||
|
||||
# Поиск обложки
|
||||
cover_path = ""
|
||||
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
|
||||
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
|
||||
candidate = f"cover{ext}"
|
||||
if candidate in user_files:
|
||||
cover_path = os.path.join(user_game_folder, candidate)
|
||||
break
|
||||
def run(self):
|
||||
games = []
|
||||
auto_dir = os.path.join(
|
||||
self.portproton_location or "", "data", "scripts", "pw_autoinstall"
|
||||
) if self.portproton_location else ""
|
||||
if not os.path.exists(auto_dir):
|
||||
self.finished.emit(games)
|
||||
return
|
||||
|
||||
if not cover_path:
|
||||
logger.debug(f"No local cover found for autoinstall {exe_name}")
|
||||
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||
if not scripts:
|
||||
self.finished.emit(games)
|
||||
return
|
||||
|
||||
# Формируем кортеж игры (добавлен exe_name в конец)
|
||||
game_tuple = (
|
||||
display_name, # name
|
||||
"", # description
|
||||
cover_path, # cover
|
||||
"", # appid
|
||||
f"autoinstall:{script_name}", # exec_line
|
||||
"", # controller_support
|
||||
"Never", # last_launch
|
||||
"0h 0m", # formatted_playtime
|
||||
"", # protondb_tier
|
||||
"", # anticheat_status
|
||||
0, # last_played
|
||||
0, # playtime_seconds
|
||||
"autoinstall", # game_source
|
||||
exe_name # exe_name
|
||||
)
|
||||
games.append(game_tuple)
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share"),
|
||||
)
|
||||
base_autoinstall_dir = os.path.join(
|
||||
xdg_data_home, "PortProtonQt", "custom_data", "autoinstall"
|
||||
)
|
||||
os.makedirs(base_autoinstall_dir, exist_ok=True)
|
||||
|
||||
callback(games)
|
||||
for script_path in scripts:
|
||||
display_name, exe_name = self.api.parse_autoinstall_script(script_path)
|
||||
script_name = os.path.splitext(os.path.basename(script_path))[0]
|
||||
|
||||
if not (display_name and exe_name):
|
||||
continue
|
||||
|
||||
exe_name = os.path.splitext(exe_name)[0]
|
||||
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
|
||||
os.makedirs(user_game_folder, exist_ok=True)
|
||||
|
||||
# Find cover
|
||||
cover_path = ""
|
||||
user_files = (
|
||||
set(os.listdir(user_game_folder))
|
||||
if os.path.exists(user_game_folder)
|
||||
else set()
|
||||
)
|
||||
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
|
||||
candidate = f"cover{ext}"
|
||||
if candidate in user_files:
|
||||
cover_path = os.path.join(user_game_folder, candidate)
|
||||
break
|
||||
|
||||
if not cover_path:
|
||||
logger.debug(f"No local cover found for autoinstall {exe_name}")
|
||||
|
||||
game_tuple = (
|
||||
display_name, "", cover_path, "", f"autoinstall:{script_name}",
|
||||
"", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
|
||||
)
|
||||
games.append(game_tuple)
|
||||
|
||||
self.api._save_autoinstall_cache(games)
|
||||
self.api._autoinstall_cache = games
|
||||
self.finished.emit(games)
|
||||
|
||||
worker = AutoinstallWorker()
|
||||
worker.api = self
|
||||
worker.portproton_location = self.portproton_location
|
||||
worker.finished.connect(lambda games: callback(games))
|
||||
worker.start()
|
||||
logger.info("Started background load of autoinstall games")
|
||||
return worker
|
||||
|
||||
def _load_topics_data(self):
|
||||
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.dialogs import generate_thumbnail
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
import shutil
|
||||
@@ -23,6 +23,7 @@ import requests
|
||||
import random
|
||||
import base64
|
||||
import glob
|
||||
import urllib.parse
|
||||
|
||||
downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
@@ -411,6 +412,39 @@ def save_app_details(app_id, data):
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
|
||||
def fetch_sgdb_cover(game_name: str) -> str:
|
||||
"""
|
||||
Fetch a cover image URL from steamgrid.usebottles.com for the given game.
|
||||
The API returns a single string (quoted URL).
|
||||
"""
|
||||
try:
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
resp = requests.get(url, timeout=5)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
|
||||
return ""
|
||||
text = resp.text.strip()
|
||||
# Убираем возможные кавычки вокруг строки
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
if text:
|
||||
logger.info("Fetched SGDB cover for %s: %s", game_name, text)
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
|
||||
return ""
|
||||
|
||||
|
||||
def check_url_exists(url: str) -> bool:
|
||||
"""Check whether a URL returns HTTP 200."""
|
||||
try:
|
||||
r = requests.head(url, timeout=3)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
|
||||
"""
|
||||
Asynchronously fetches detailed app info from Steam API.
|
||||
@@ -629,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
|
||||
title = decode_text(app_info.get("name", ""))
|
||||
description = decode_text(app_info.get("short_description", ""))
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
if not check_url_exists(cover):
|
||||
logger.info("Steam cover not found for %s, trying SGDB", title)
|
||||
alt_cover = fetch_sgdb_cover(title)
|
||||
if alt_cover:
|
||||
cover = alt_cover
|
||||
|
||||
def on_protondb_tier(tier: str):
|
||||
def on_anticheat_status(anticheat_status: str):
|
||||
@@ -722,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
game_name = desktop_name or exe_name.capitalize()
|
||||
|
||||
if not matching_app:
|
||||
cover = fetch_sgdb_cover(game_name) or ""
|
||||
logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
|
||||
|
||||
def on_anticheat_status(anticheat_status: str):
|
||||
callback({
|
||||
"appid": "",
|
||||
"name": decode_text(game_name),
|
||||
"description": "",
|
||||
"cover": "",
|
||||
"cover": cover,
|
||||
"controller_support": "",
|
||||
"protondb_tier": "",
|
||||
"steam_game": "false",
|
||||
@@ -758,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
title = decode_text(app_info.get("name", game_name))
|
||||
description = decode_text(app_info.get("short_description", ""))
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
if not check_url_exists(cover):
|
||||
logger.info("Steam cover not found for %s, trying SGDB", title)
|
||||
alt_cover = fetch_sgdb_cover(title)
|
||||
if alt_cover:
|
||||
cover = alt_cover
|
||||
controller_support = app_info.get("controller_support", "")
|
||||
|
||||
def on_protondb_tier(tier: str):
|
||||
@@ -957,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
portproton_dir = get_portproton_location()
|
||||
if not portproton_dir:
|
||||
start_sh = get_portproton_start_command()
|
||||
if not portproton_dir or not start_sh:
|
||||
logger.error("PortProton directory not found")
|
||||
return (False, "PortProton directory not found")
|
||||
|
||||
@@ -966,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
|
||||
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
|
||||
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
|
||||
|
||||
if not os.path.exists(start_sh_path):
|
||||
logger.error(f"start.sh not found at {start_sh_path}")
|
||||
return (False, f"start.sh not found at {start_sh_path}")
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
script_content = f"""#!/usr/bin/env bash
|
||||
export LD_PRELOAD=
|
||||
export START_FROM_STEAM=1
|
||||
"{start_sh_path}" "{exe_path}" "$@"
|
||||
"{start_sh}" "{exe_path}" "$@"
|
||||
"""
|
||||
try:
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
|
||||
1
portprotonqt/themes/standart/images/icons/settings.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 232 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
Normal file
|
After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
portprotonqt/themes/standart/images/screenshots/Карточка.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
portprotonqt/themes/standart/images/screenshots/Темы.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
@@ -1,7 +1,8 @@
|
||||
from typing import cast
|
||||
from typing import cast, Any
|
||||
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
|
||||
QSizePolicy, QWidget, QLineEdit)
|
||||
from PySide6.QtCore import Qt, Signal, QProcess
|
||||
from PySide6.QtCore import Qt, Signal, QProcess, QSize
|
||||
from PySide6.QtGui import QPixmap, QIcon
|
||||
from portprotonqt.keyboard_layouts import keyboard_layouts
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
@@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame):
|
||||
self.margins = 10
|
||||
self.num_cols = 14
|
||||
|
||||
# Find input_manager and main_window
|
||||
self.input_manager: Any = None
|
||||
self.main_window: Any = None
|
||||
parent_widget: QWidget | None = self._parent
|
||||
while parent_widget:
|
||||
if hasattr(parent_widget, 'input_manager'):
|
||||
self.input_manager = cast(Any, parent_widget).input_manager
|
||||
self.main_window = cast(Any, parent_widget)
|
||||
parent_widget = cast(QWidget | None, parent_widget.parent())
|
||||
|
||||
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
self.initUI()
|
||||
self.hide()
|
||||
|
||||
@@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame):
|
||||
self.buttons: dict[str, QPushButton] = {}
|
||||
self.update_keyboard()
|
||||
|
||||
def set_gamepad_icon(self, button, icon_type, gtype=''):
|
||||
"""Set gamepad icon on button based on type"""
|
||||
if icon_type in ['back', 'add_game']:
|
||||
icon_name = self.main_window.get_button_icon(icon_type, gtype)
|
||||
else: # nav left/right
|
||||
if icon_type in ['left', 'right']:
|
||||
direction = icon_type
|
||||
icon_name = self.main_window.get_nav_icon(direction, gtype)
|
||||
else:
|
||||
direction = 'left' if icon_type == 'left' else 'right'
|
||||
icon_name = self.main_window.get_nav_icon(direction, gtype)
|
||||
|
||||
icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name)
|
||||
pixmap = QPixmap()
|
||||
if icon_path:
|
||||
pixmap.load(str(icon_path))
|
||||
if not pixmap.isNull():
|
||||
button.setIcon(QIcon(pixmap))
|
||||
button.setIconSize(QSize(20, 20))
|
||||
return
|
||||
else:
|
||||
# Fallback to placeholder
|
||||
placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name)
|
||||
if placeholder:
|
||||
button.setIcon(QIcon(placeholder))
|
||||
button.setIconSize(QSize(20, 20))
|
||||
return
|
||||
|
||||
def update_keyboard(self):
|
||||
coords = self._save_focused_coords()
|
||||
|
||||
@@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame):
|
||||
button.setCheckable(True)
|
||||
button.setChecked(self.shift_pressed)
|
||||
button.clicked.connect(lambda checked: self.on_shift_click(checked))
|
||||
# Add gamepad icon for Shift (RB/R)
|
||||
gtype = self.input_manager.gamepad_type
|
||||
self.set_gamepad_icon(button, 'right', gtype)
|
||||
else:
|
||||
button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
|
||||
|
||||
@@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame):
|
||||
shift.setCheckable(True)
|
||||
shift.setChecked(self.shift_pressed)
|
||||
shift.clicked.connect(lambda checked: self.on_shift_click(checked))
|
||||
# Add gamepad icon for Shift (RB/R)
|
||||
gtype = self.input_manager.gamepad_type
|
||||
self.set_gamepad_icon(shift, 'right', gtype)
|
||||
self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
|
||||
|
||||
button = QPushButton('CAPS')
|
||||
@@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame):
|
||||
backspace.setFixedSize(fixed_w, fixed_h)
|
||||
backspace.pressed.connect(self.on_backspace_pressed)
|
||||
backspace.released.connect(self.stop_backspace_repeat)
|
||||
# Add gamepad icon for Backspace (X/Triangle)
|
||||
gtype = self.input_manager.gamepad_type
|
||||
self.set_gamepad_icon(backspace, 'add_game', gtype)
|
||||
self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
|
||||
|
||||
enter = QPushButton('Enter')
|
||||
@@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame):
|
||||
lang = QPushButton('🌐')
|
||||
lang.setFixedSize(fixed_w, fixed_h)
|
||||
lang.clicked.connect(self.on_lang_click)
|
||||
# Add gamepad icon for Lang (LB/L)
|
||||
gtype = self.input_manager.gamepad_type
|
||||
self.set_gamepad_icon(lang, 'left', gtype)
|
||||
self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
|
||||
|
||||
clear = QPushButton('Clear')
|
||||
@@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame):
|
||||
hide_button = QPushButton('Hide')
|
||||
hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
|
||||
hide_button.clicked.connect(self.hide)
|
||||
# Add gamepad icon for Hide (B/Circle)
|
||||
gtype = self.input_manager.gamepad_type
|
||||
self.set_gamepad_icon(hide_button, 'back', gtype)
|
||||
self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
|
||||
|
||||
if coords:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
|
||||