Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5e79dc17 | ||
|
|
bf02c49412 | ||
|
|
c3a00f469c | ||
|
|
d5f7e4dbfd |
@@ -18,8 +18,8 @@
|
|||||||
|
|
||||||
## О проекте
|
## О проекте
|
||||||
|
|
||||||
Vual — приложение для запуска Cheat Engine в Steam-играх, работающих через Proton.
|
Vual — приложение для запуска [Cheat Engine](https://github.com/cheat-engine/cheat-engine) в Steam-играх, работающих через Proton.
|
||||||
Показывает библиотеку Steam, управляет protonhax и launch options, скачивает и
|
Показывает библиотеку Steam, управляет [protonhax](https://github.com/jcnils/protonhax) и launch options, скачивает и
|
||||||
распаковывает CE автоматически, запускает его в нужном Wine-префиксе — всё из одного
|
распаковывает CE автоматически, запускает его в нужном Wine-префиксе — всё из одного
|
||||||
окна.
|
окна.
|
||||||
|
|
||||||
@@ -35,12 +35,21 @@ Vual — приложение для запуска Cheat Engine в Steam-игр
|
|||||||
- **Прозрачность окна** — полупрозрачный фон в стиле frosted glass
|
- **Прозрачность окна** — полупрозрачный фон в стиле frosted glass
|
||||||
- **Гайд** — встроенная страница быстрого старта при первом запуске и из меню
|
- **Гайд** — встроенная страница быстрого старта при первом запуске и из меню
|
||||||
|
|
||||||
|
## Скриншоты
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="data/screenshots/main_page.png" alt="Главная" height="300">
|
||||||
|
<img src="data/screenshots/pref_app.png" alt="Внешний вид" height="300">
|
||||||
|
<img src="data/screenshots/pref_steam.png" alt="Steam" height="300">
|
||||||
|
<img src="data/screenshots/pref_ce.png" alt="Cheat Engine" height="300">
|
||||||
|
</p>
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
### Stapler
|
### Stapler
|
||||||
|
|
||||||
```
|
```
|
||||||
stplr repo add lumenara https://github.com/Cheviiot/Lumenara.git
|
stplr repo add luma https://github.com/Cheviiot/Luma.git
|
||||||
stplr refresh && stplr install vual
|
stplr refresh && stplr install vual
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,12 +64,12 @@ cd vual
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>Зависимости</strong></summary>
|
<summary><strong>Зависимости</strong></summary>
|
||||||
|
|
||||||
| Дистрибутив | Команда |
|
| Дистрибутив | Команда |
|
||||||
|---|---|
|
|------------------|---------------------------------------------------------------------------------|
|
||||||
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` |
|
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` |
|
||||||
| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-requests` |
|
| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-requests` |
|
||||||
| Arch | `pacman -S python-gobject gtk4 libadwaita python-requests` |
|
| Arch | `pacman -S python-gobject gtk4 libadwaita python-requests` |
|
||||||
| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 python3-requests` |
|
| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 python3-requests` |
|
||||||
|
|
||||||
Также нужен **Proton** (через Steam) — для распаковки CE и работы protonhax.
|
Также нужен **Proton** (через Steam) — для распаковки CE и работы protonhax.
|
||||||
|
|
||||||
@@ -68,63 +77,62 @@ cd vual
|
|||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
```
|
### Перед началом
|
||||||
./vual
|
|
||||||
```
|
|
||||||
|
|
||||||
1. При первом запуске откроется встроенный гайд
|
- Установи или скачай Cheat Engine в настройках
|
||||||
2. Перейди в **Настройки → Cheat Engine** — скачай и распакуй CE
|
- Проверь, что путь к Steam указывает на правильную библиотеку
|
||||||
3. Установи **protonhax** там же
|
- Закрой Steam перед массовым изменением launch options
|
||||||
4. Включи переключатель у нужной игры — Vual пропишет launch options
|
|
||||||
5. Нажми ▶ — игра запустится, CE откроется автоматически
|
### Как работает Vual
|
||||||
|
|
||||||
|
| Шаг | | |
|
||||||
|
|---|---|---|
|
||||||
|
| **1** | **Выбери игру** | Найди игру в библиотеке и включи переключатель на её плитке |
|
||||||
|
| **2** | **Запусти через Steam** | Steam запустит игру с protonhax и подготовит нужное окружение |
|
||||||
|
| **3** | **Открой Cheat Engine** | Используй меню плитки, чтобы запустить CE для работающей игры |
|
||||||
|
| **4** | **Привяжи таблицу** | Привяжи .CT-файл один раз — Vual будет передавать его в CE автоматически |
|
||||||
|
|
||||||
|
### Полезно знать
|
||||||
|
|
||||||
|
- Поиск в заголовке быстро фильтрует большие библиотеки
|
||||||
|
- Кнопка меню на каждой плитке содержит действия CE и привязку таблиц
|
||||||
|
- Прозрачность окна включается в настройках внешнего вида
|
||||||
|
|
||||||
## Управление
|
## Управление
|
||||||
|
|
||||||
| | |
|
| Действие | Как |
|
||||||
|---|---|
|
|--------------------|-----------------------------------------|
|
||||||
| Запуск CE | Клик по кнопке ▶ на плитке игры |
|
| Запуск CE | Кнопка ▶ на плитке игры |
|
||||||
| Поиск | Клик по полю поиска в заголовке |
|
| Поиск | Поле поиска в заголовке |
|
||||||
| Привязать таблицу | Правая кнопка мыши → Assign Table |
|
| Привязать таблицу | Меню плитки → Привязать таблицу |
|
||||||
| Вкл/выкл protonhax | Переключатель на плитке игры |
|
| Вкл/выкл protonhax| Переключатель на плитке |
|
||||||
| Массовое вкл/выкл | Кнопки Enable All / Disable All |
|
| Массовое вкл/выкл | Включить все / Отключить все |
|
||||||
| Сортировка | Меню сортировки в заголовке |
|
| Сортировка | Меню сортировки в заголовке |
|
||||||
| Настройки | Главное меню → Preferences |
|
| Настройки | ☰ → Настройки |
|
||||||
| Гайд | Главное меню → Guide |
|
| Гайд | ☰ → Гайд |
|
||||||
|
|
||||||
## Настройки
|
## Настройки
|
||||||
|
|
||||||
| | | По умолчанию |
|
| Параметр | Варианты | По умолчанию |
|
||||||
|---|---|---|
|
|--------------------|------------------------------|--------------|
|
||||||
| Тема | Системная / Светлая / Тёмная | Системная |
|
| Тема | Системная / Светлая / Тёмная | Системная |
|
||||||
| Язык | Авто / English / Русский | Авто |
|
| Язык | Авто / Английский / Русский | Авто |
|
||||||
| Размер плиток | Small / Medium / Large | Medium |
|
| Размер плиток | Маленький / Средний / Большой| Средний |
|
||||||
| Сортировка | По имени / По статусу | По имени |
|
| Сортировка | По имени / По статусу | По имени |
|
||||||
| Прозрачность окна | вкл / выкл | выкл |
|
| Прозрачность окна | вкл / выкл | выкл |
|
||||||
| Тема Wine | Тёмная / Светлая | — |
|
| Тема Wine | Системная / Тёмная / Светлая | Системная |
|
||||||
| Язык CE | EN / RU | — |
|
| Язык CE | Системный / Русский | Системный |
|
||||||
|
|
||||||
## Данные
|
## Данные
|
||||||
|
|
||||||
`~/.config/vual/`
|
| Путь | Содержимое |
|
||||||
|
|-----------------------------------|-------------------------------------|
|
||||||
| | |
|
| `~/.config/vual/config.json` | Настройки |
|
||||||
|---|---|
|
| `~/.local/share/vual/cheatengine/`| Cheat Engine |
|
||||||
| config.json | Настройки |
|
| `~/.local/share/vual/bin/protonhax`| protonhax |
|
||||||
|
| `~/.local/share/vual/tables/` | .CT-таблицы и привязки |
|
||||||
`~/.local/share/vual/`
|
| `~/.local/share/vual/wine_prefix/`| Wine-префикс для тестового запуска |
|
||||||
|
| `~/.cache/vual/covers/` | Кэш обложек Steam |
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| cheatengine/ | Cheat Engine |
|
|
||||||
| bin/protonhax | protonhax |
|
|
||||||
| tables/ | .CT-таблицы и привязки |
|
|
||||||
| wine_prefix/ | Wine-префикс для тестового запуска CE |
|
|
||||||
|
|
||||||
`~/.cache/vual/`
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| covers/ | Кэш обложек Steam |
|
|
||||||
|
|
||||||
## Участие в разработке
|
## Участие в разработке
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1020 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 911 B After Width: | Height: | Size: 899 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.4 KiB |
@@ -64,7 +64,22 @@
|
|||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<caption>Steam game library with covers</caption>
|
<caption>Steam game library with covers</caption>
|
||||||
<caption xml:lang="ru">Библиотека Steam с обложками</caption>
|
<caption xml:lang="ru">Библиотека Steam с обложками</caption>
|
||||||
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main.png</image>
|
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main_page.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Appearance settings</caption>
|
||||||
|
<caption xml:lang="ru">Настройки внешнего вида</caption>
|
||||||
|
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/pref_app.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Steam settings</caption>
|
||||||
|
<caption xml:lang="ru">Настройки Steam</caption>
|
||||||
|
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/pref_steam.png</image>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<caption>Cheat Engine settings</caption>
|
||||||
|
<caption xml:lang="ru">Настройки Cheat Engine</caption>
|
||||||
|
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/pref_ce.png</image>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
|
|
||||||
@@ -82,6 +97,18 @@
|
|||||||
</provides>
|
</provides>
|
||||||
|
|
||||||
<releases>
|
<releases>
|
||||||
|
<release version="0.3.1" date="2026-03-19">
|
||||||
|
<description>
|
||||||
|
<p>Open tables folder from tile menu, shortened menu labels, language selector fully localized, screenshot height fix.</p>
|
||||||
|
<p xml:lang="ru">Открытие папки таблиц из меню, сокращённые пункты меню, полная локализация выбора языка, исправление высоты скриншотов.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
|
<release version="0.3.0" date="2026-03-19">
|
||||||
|
<description>
|
||||||
|
<p>Code split into modules, new icon, screenshots, version check before download, full localization sync.</p>
|
||||||
|
<p xml:lang="ru">Код разбит на модули, новая иконка, скриншоты, проверка версии перед скачиванием, полная синхронизация локализации.</p>
|
||||||
|
</description>
|
||||||
|
</release>
|
||||||
<release version="0.2.0" date="2026-03-19">
|
<release version="0.2.0" date="2026-03-19">
|
||||||
<description>
|
<description>
|
||||||
<p>CE table binding, Wine theme manager, built-in guide, window transparency, Adwyra-inspired theme, Russian CE locale installer.</p>
|
<p>CE table binding, Wine theme manager, built-in guide, window transparency, Adwyra-inspired theme, Russian CE locale installer.</p>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: vual 0.2.0\n"
|
"Project-Id-Version: vual 0.3.1\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
|
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
|
||||||
"PO-Revision-Date: 2026-03-19 00:00+0000\n"
|
"PO-Revision-Date: 2026-03-19 00:00+0000\n"
|
||||||
@@ -248,15 +248,18 @@ msgstr "Refresh cover"
|
|||||||
msgid "Launch game"
|
msgid "Launch game"
|
||||||
msgstr "Launch game"
|
msgstr "Launch game"
|
||||||
|
|
||||||
msgid "Launch Cheat Engine"
|
msgid "Launch CE"
|
||||||
msgstr "Launch Cheat Engine"
|
msgstr "Launch CE"
|
||||||
|
|
||||||
msgid "Assign table…"
|
msgid "Assign table…"
|
||||||
msgstr "Assign table…"
|
msgstr "Assign…"
|
||||||
|
|
||||||
msgid "Remove table"
|
msgid "Remove table"
|
||||||
msgstr "Remove table"
|
msgstr "Remove table"
|
||||||
|
|
||||||
|
msgid "Tables folder"
|
||||||
|
msgstr "Tables folder"
|
||||||
|
|
||||||
msgid "Select Cheat Engine table"
|
msgid "Select Cheat Engine table"
|
||||||
msgstr "Select Cheat Engine table"
|
msgstr "Select Cheat Engine table"
|
||||||
|
|
||||||
@@ -577,3 +580,18 @@ msgstr "Update"
|
|||||||
|
|
||||||
msgid "Wine not found — install Proton"
|
msgid "Wine not found — install Proton"
|
||||||
msgstr "Wine not found — install Proton"
|
msgstr "Wine not found — install Proton"
|
||||||
|
|
||||||
|
msgid "already installed"
|
||||||
|
msgstr "already installed"
|
||||||
|
|
||||||
|
msgid "Transparent window"
|
||||||
|
msgstr "Transparent window"
|
||||||
|
|
||||||
|
msgid "Semi-transparent background"
|
||||||
|
msgstr "Semi-transparent background"
|
||||||
|
|
||||||
|
msgid "English"
|
||||||
|
msgstr "English"
|
||||||
|
|
||||||
|
msgid "Русский"
|
||||||
|
msgstr "Russian"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: vual 0.2.0\n"
|
"Project-Id-Version: vual 0.3.1\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
|
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
|
||||||
"PO-Revision-Date: 2026-03-19 00:00+0000\n"
|
"PO-Revision-Date: 2026-03-19 00:00+0000\n"
|
||||||
@@ -254,15 +254,18 @@ msgstr "Обновить обложку"
|
|||||||
msgid "Launch game"
|
msgid "Launch game"
|
||||||
msgstr "Запустить игру"
|
msgstr "Запустить игру"
|
||||||
|
|
||||||
msgid "Launch Cheat Engine"
|
msgid "Launch CE"
|
||||||
msgstr "Запустить Cheat Engine"
|
msgstr "Запуск CE"
|
||||||
|
|
||||||
msgid "Assign table…"
|
msgid "Assign table…"
|
||||||
msgstr "Назначить таблицу…"
|
msgstr "Назначить…"
|
||||||
|
|
||||||
msgid "Remove table"
|
msgid "Remove table"
|
||||||
msgstr "Убрать таблицу"
|
msgstr "Убрать таблицу"
|
||||||
|
|
||||||
|
msgid "Tables folder"
|
||||||
|
msgstr "Папка таблиц"
|
||||||
|
|
||||||
msgid "Select Cheat Engine table"
|
msgid "Select Cheat Engine table"
|
||||||
msgstr "Выберите таблицу Cheat Engine"
|
msgstr "Выберите таблицу Cheat Engine"
|
||||||
|
|
||||||
@@ -344,10 +347,10 @@ msgstr "О программе"
|
|||||||
|
|
||||||
# Guide
|
# Guide
|
||||||
msgid "Guide"
|
msgid "Guide"
|
||||||
msgstr "Гайд"
|
msgstr "Справка"
|
||||||
|
|
||||||
msgid "Close guide"
|
msgid "Close guide"
|
||||||
msgstr "Закрыть гайд"
|
msgstr "Закрыть Справку"
|
||||||
|
|
||||||
msgid "Welcome to Vual"
|
msgid "Welcome to Vual"
|
||||||
msgstr "Добро пожаловать в Vual"
|
msgstr "Добро пожаловать в Vual"
|
||||||
@@ -586,3 +589,12 @@ msgstr "Обновить"
|
|||||||
|
|
||||||
msgid "Wine not found — install Proton"
|
msgid "Wine not found — install Proton"
|
||||||
msgstr "Wine не найден — установите Proton"
|
msgstr "Wine не найден — установите Proton"
|
||||||
|
|
||||||
|
msgid "already installed"
|
||||||
|
msgstr "уже установлена"
|
||||||
|
|
||||||
|
msgid "English"
|
||||||
|
msgstr "Английский"
|
||||||
|
|
||||||
|
msgid "Русский"
|
||||||
|
msgstr "Русский"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "vual"
|
name = "vual"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
description = "Launch Cheat Engine for Steam games via Proton"
|
description = "Launch Cheat Engine for Steam games via Proton"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.3.1"
|
||||||
APP_ID = "io.github.vual"
|
APP_ID = "io.github.vual"
|
||||||
APP_NAME = "Vual"
|
APP_NAME = "Vual"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Cover image loading and caching utilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
|
|
||||||
|
from gi.repository import GdkPixbuf, GLib # noqa: E402
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Steam cover URLs
|
||||||
|
COVER_URL = "https://steamcdn-a.akamaihd.net/steam/apps/{}/library_600x900_2x.jpg"
|
||||||
|
COVER_FALLBACK = "https://steamcdn-a.akamaihd.net/steam/apps/{}/header.jpg"
|
||||||
|
|
||||||
|
# Cache directory
|
||||||
|
CACHE_DIR = Path(GLib.get_user_cache_dir()) / "vual" / "covers"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_path(app_id: str) -> Path:
|
||||||
|
"""Get cache path for cover image."""
|
||||||
|
return CACHE_DIR / f"{app_id}.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def download_cover(app_id: str) -> Path | None:
|
||||||
|
"""Download cover from Steam CDN. Returns cache path or None."""
|
||||||
|
cache = cache_path(app_id)
|
||||||
|
if cache.exists():
|
||||||
|
return cache
|
||||||
|
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for url_template in (COVER_URL, COVER_FALLBACK):
|
||||||
|
url = url_template.format(app_id)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers={"User-Agent": "Vual/1.0"}, timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
cache.write_bytes(resp.content)
|
||||||
|
return cache
|
||||||
|
except requests.RequestException:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_pixbuf(path: Path, width: int, height: int) -> GdkPixbuf.Pixbuf | None:
|
||||||
|
"""Load and scale pixbuf from file.
|
||||||
|
|
||||||
|
Handles landscape images by compositing them onto a blurred background.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||||
|
except GLib.Error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ow, oh = original.get_width(), original.get_height()
|
||||||
|
if ow < 1 or oh < 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
aspect = ow / oh
|
||||||
|
|
||||||
|
# If image is landscape (wider than 4:3), create composite with blur
|
||||||
|
if aspect > 1.33:
|
||||||
|
return _create_landscape_composite(original, ow, oh, width, height)
|
||||||
|
|
||||||
|
# Portrait or square: scale to fill
|
||||||
|
return original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_landscape_composite(
|
||||||
|
original: GdkPixbuf.Pixbuf,
|
||||||
|
ow: int, oh: int,
|
||||||
|
width: int, height: int,
|
||||||
|
) -> GdkPixbuf.Pixbuf:
|
||||||
|
"""Create a portrait tile from a landscape image.
|
||||||
|
|
||||||
|
Composites the original image centered on a scaled/blurred background.
|
||||||
|
"""
|
||||||
|
# Create background: scale original to fill tile (will be cropped/stretched)
|
||||||
|
bg = original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||||
|
|
||||||
|
# Apply simple darkening to background (simulates blur effect)
|
||||||
|
dark = GdkPixbuf.Pixbuf.new(
|
||||||
|
GdkPixbuf.Colorspace.RGB, True, 8, width, height
|
||||||
|
)
|
||||||
|
dark.fill(0x00000099)
|
||||||
|
dark.composite(
|
||||||
|
bg, 0, 0, width, height,
|
||||||
|
0, 0, 1.0, 1.0,
|
||||||
|
GdkPixbuf.InterpType.NEAREST, 180,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scale original to fit width while preserving aspect ratio
|
||||||
|
scale = width / ow
|
||||||
|
scaled_w = width
|
||||||
|
scaled_h = int(oh * scale)
|
||||||
|
|
||||||
|
scaled = original.scale_simple(scaled_w, scaled_h, GdkPixbuf.InterpType.BILINEAR)
|
||||||
|
|
||||||
|
# Center vertically
|
||||||
|
y_offset = (height - scaled_h) // 2
|
||||||
|
|
||||||
|
# Composite scaled image onto darkened background
|
||||||
|
scaled.composite(
|
||||||
|
bg,
|
||||||
|
0, y_offset,
|
||||||
|
scaled_w, scaled_h,
|
||||||
|
0, y_offset,
|
||||||
|
1.0, 1.0,
|
||||||
|
GdkPixbuf.InterpType.BILINEAR,
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
|
||||||
|
return bg
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""GameTile — single game tile widget."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Adw", "1")
|
||||||
|
gi.require_version("Gdk", "4.0")
|
||||||
|
gi.require_version("GdkPixbuf", "2.0")
|
||||||
|
gi.require_version("Pango", "1.0")
|
||||||
|
|
||||||
|
from gi.repository import Gdk, GdkPixbuf, Gio, Gtk, Pango # noqa: E402
|
||||||
|
|
||||||
|
from vual import tables # noqa: E402
|
||||||
|
from vual.i18n import _ # noqa: E402
|
||||||
|
|
||||||
|
COVER_RATIO = 1.5 # Height = Width * 1.5 (portrait)
|
||||||
|
|
||||||
|
|
||||||
|
class GameTile(Gtk.Overlay):
|
||||||
|
"""A single game tile with cover image and controls overlay."""
|
||||||
|
|
||||||
|
__gtype_name__ = "VualGameTile"
|
||||||
|
|
||||||
|
def __init__(self, app_id: str, name: str, is_active: bool, tile_width: int):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self._app_id = app_id
|
||||||
|
self._name = name
|
||||||
|
self._is_active = is_active
|
||||||
|
self._is_running = False
|
||||||
|
self._width = tile_width
|
||||||
|
self._height = int(tile_width * COVER_RATIO)
|
||||||
|
|
||||||
|
# Fixed size container
|
||||||
|
self.set_size_request(self._width, self._height)
|
||||||
|
self.add_css_class("vual-tile")
|
||||||
|
self.set_overflow(Gtk.Overflow.HIDDEN)
|
||||||
|
|
||||||
|
# Cover image (fills the tile)
|
||||||
|
self._picture = Gtk.Picture()
|
||||||
|
self._picture.set_content_fit(Gtk.ContentFit.COVER)
|
||||||
|
self._picture.set_size_request(self._width, self._height)
|
||||||
|
self._picture.add_css_class("skeleton")
|
||||||
|
self.set_child(self._picture)
|
||||||
|
|
||||||
|
# Bottom info overlay
|
||||||
|
self._build_info_box()
|
||||||
|
|
||||||
|
# Top-left reload button
|
||||||
|
self._build_reload_button()
|
||||||
|
|
||||||
|
# Top-right running badge
|
||||||
|
self._build_running_badge()
|
||||||
|
|
||||||
|
def _build_info_box(self) -> None:
|
||||||
|
"""Create bottom info panel with name, buttons, and switch."""
|
||||||
|
info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
info.add_css_class("vual-tile-info")
|
||||||
|
info.set_valign(Gtk.Align.END)
|
||||||
|
info.set_halign(Gtk.Align.FILL)
|
||||||
|
|
||||||
|
# Game name
|
||||||
|
label = Gtk.Label(label=self._name)
|
||||||
|
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||||
|
label.set_max_width_chars(15)
|
||||||
|
label.set_halign(Gtk.Align.START)
|
||||||
|
label.add_css_class("heading")
|
||||||
|
info.append(label)
|
||||||
|
|
||||||
|
# Controls row: [Play] [CE] ---- [Switch]
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
row.set_halign(Gtk.Align.FILL)
|
||||||
|
|
||||||
|
# Play button
|
||||||
|
btn_play = Gtk.Button()
|
||||||
|
btn_play.set_icon_name("media-playback-start-symbolic")
|
||||||
|
btn_play.add_css_class("flat")
|
||||||
|
btn_play.add_css_class("tile-btn")
|
||||||
|
btn_play.set_tooltip_text(_("Launch game"))
|
||||||
|
btn_play.connect("clicked", self._on_play_clicked)
|
||||||
|
row.append(btn_play)
|
||||||
|
|
||||||
|
# CE button with table menu
|
||||||
|
self._btn_ce = Gtk.MenuButton()
|
||||||
|
self._btn_ce.set_icon_name("open-menu-symbolic")
|
||||||
|
self._btn_ce.add_css_class("flat")
|
||||||
|
self._btn_ce.add_css_class("tile-btn")
|
||||||
|
self._update_ce_tooltip()
|
||||||
|
|
||||||
|
ce_menu = Gio.Menu()
|
||||||
|
ce_menu.append(_("Launch CE"), "tile.launch-ce")
|
||||||
|
ce_menu.append(_("Tables folder"), "tile.open-tables-folder")
|
||||||
|
ce_menu.append(_("Assign table\u2026"), "tile.assign-table")
|
||||||
|
ce_menu.append(_("Remove table"), "tile.remove-table")
|
||||||
|
|
||||||
|
pop = Gtk.PopoverMenu.new_from_model(ce_menu)
|
||||||
|
self._btn_ce.set_popover(pop)
|
||||||
|
|
||||||
|
# Actions
|
||||||
|
group = Gio.SimpleActionGroup()
|
||||||
|
|
||||||
|
act_launch = Gio.SimpleAction.new("launch-ce", None)
|
||||||
|
act_launch.connect("activate", lambda *_: self._on_ce_clicked(None))
|
||||||
|
group.add_action(act_launch)
|
||||||
|
|
||||||
|
act_assign = Gio.SimpleAction.new("assign-table", None)
|
||||||
|
act_assign.connect("activate", lambda *_: self._on_assign_table())
|
||||||
|
group.add_action(act_assign)
|
||||||
|
|
||||||
|
self._act_remove = Gio.SimpleAction.new("remove-table", None)
|
||||||
|
self._act_remove.connect("activate", lambda *_: self._on_remove_table())
|
||||||
|
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
|
||||||
|
group.add_action(self._act_remove)
|
||||||
|
|
||||||
|
act_open_folder = Gio.SimpleAction.new("open-tables-folder", None)
|
||||||
|
act_open_folder.connect("activate", lambda *_: self._on_open_tables_folder())
|
||||||
|
group.add_action(act_open_folder)
|
||||||
|
|
||||||
|
self.insert_action_group("tile", group)
|
||||||
|
row.append(self._btn_ce)
|
||||||
|
|
||||||
|
# Spacer
|
||||||
|
spacer = Gtk.Box()
|
||||||
|
spacer.set_hexpand(True)
|
||||||
|
row.append(spacer)
|
||||||
|
|
||||||
|
# Active switch
|
||||||
|
self._switch = Gtk.Switch()
|
||||||
|
self._switch.set_valign(Gtk.Align.CENTER)
|
||||||
|
self._switch.set_active(self._is_active)
|
||||||
|
self._switch.connect("state-set", self._on_switch_toggled)
|
||||||
|
row.append(self._switch)
|
||||||
|
|
||||||
|
info.append(row)
|
||||||
|
self.add_overlay(info)
|
||||||
|
|
||||||
|
def _build_reload_button(self) -> None:
|
||||||
|
"""Create reload button in top-left corner."""
|
||||||
|
btn = Gtk.Button()
|
||||||
|
btn.set_icon_name("view-refresh-symbolic")
|
||||||
|
btn.add_css_class("flat")
|
||||||
|
btn.add_css_class("vual-reload-btn")
|
||||||
|
btn.set_valign(Gtk.Align.START)
|
||||||
|
btn.set_halign(Gtk.Align.START)
|
||||||
|
btn.set_margin_top(6)
|
||||||
|
btn.set_margin_start(6)
|
||||||
|
btn.set_tooltip_text(_("Refresh cover"))
|
||||||
|
btn.connect("clicked", self._on_reload_clicked)
|
||||||
|
self.add_overlay(btn)
|
||||||
|
|
||||||
|
def _build_running_badge(self) -> None:
|
||||||
|
"""Create running badge in top-right corner."""
|
||||||
|
self._badge = Gtk.Box()
|
||||||
|
self._badge.add_css_class("vual-badge")
|
||||||
|
self._badge.set_valign(Gtk.Align.START)
|
||||||
|
self._badge.set_halign(Gtk.Align.END)
|
||||||
|
self._badge.set_margin_top(6)
|
||||||
|
self._badge.set_margin_end(6)
|
||||||
|
self._badge.set_visible(False)
|
||||||
|
self.add_overlay(self._badge)
|
||||||
|
|
||||||
|
# ─── Properties ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app_id(self) -> str:
|
||||||
|
return self._app_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self._is_active
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._is_running
|
||||||
|
|
||||||
|
# ─── Setters ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_active_silent(self, active: bool) -> None:
|
||||||
|
"""Set switch without triggering callback."""
|
||||||
|
self._is_active = active
|
||||||
|
self._switch.handler_block_by_func(self._on_switch_toggled)
|
||||||
|
self._switch.set_active(active)
|
||||||
|
self._switch.handler_unblock_by_func(self._on_switch_toggled)
|
||||||
|
|
||||||
|
def set_running(self, running: bool) -> None:
|
||||||
|
"""Update running badge visibility."""
|
||||||
|
self._is_running = running
|
||||||
|
self._badge.set_visible(running)
|
||||||
|
|
||||||
|
def set_cover(self, pixbuf: GdkPixbuf.Pixbuf | None) -> None:
|
||||||
|
"""Set cover image from pixbuf."""
|
||||||
|
self._picture.remove_css_class("skeleton")
|
||||||
|
if pixbuf:
|
||||||
|
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||||
|
self._picture.set_paintable(texture)
|
||||||
|
else:
|
||||||
|
self._picture.set_paintable(None)
|
||||||
|
|
||||||
|
def resize(self, width: int) -> None:
|
||||||
|
"""Resize tile to new width."""
|
||||||
|
self._width = width
|
||||||
|
self._height = int(width * COVER_RATIO)
|
||||||
|
self.set_size_request(self._width, self._height)
|
||||||
|
self._picture.set_size_request(self._width, self._height)
|
||||||
|
|
||||||
|
# ─── Callbacks ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_play_clicked(self, _btn: Gtk.Button) -> None:
|
||||||
|
if hasattr(self, "_on_launch"):
|
||||||
|
self._on_launch(self._app_id, self._name)
|
||||||
|
|
||||||
|
def _on_ce_clicked(self, _btn: Gtk.Button | None) -> None:
|
||||||
|
if hasattr(self, "_on_launch_ce"):
|
||||||
|
self._on_launch_ce(self._app_id, self._name)
|
||||||
|
|
||||||
|
def _on_assign_table(self) -> None:
|
||||||
|
if hasattr(self, "_on_pick_table"):
|
||||||
|
self._on_pick_table(self._app_id)
|
||||||
|
|
||||||
|
def _on_remove_table(self) -> None:
|
||||||
|
tables.unbind(self._app_id)
|
||||||
|
self._act_remove.set_enabled(False)
|
||||||
|
self._update_ce_tooltip()
|
||||||
|
|
||||||
|
def _on_open_tables_folder(self) -> None:
|
||||||
|
tables.TABLES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
Gtk.FileLauncher.new(Gio.File.new_for_path(str(tables.TABLES_DIR))).launch(None, None, None)
|
||||||
|
|
||||||
|
def _update_ce_tooltip(self) -> None:
|
||||||
|
name = tables.get_table_name(self._app_id)
|
||||||
|
if name:
|
||||||
|
self._btn_ce.set_tooltip_text(f"CE: {name}")
|
||||||
|
else:
|
||||||
|
self._btn_ce.set_tooltip_text(_("Launch CE"))
|
||||||
|
|
||||||
|
def mark_table_bound(self) -> None:
|
||||||
|
"""Refresh table indicator after binding."""
|
||||||
|
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
|
||||||
|
self._update_ce_tooltip()
|
||||||
|
|
||||||
|
def _on_switch_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
|
||||||
|
self._is_active = state
|
||||||
|
if hasattr(self, "_on_toggle"):
|
||||||
|
self._on_toggle(self._app_id, state)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_reload_clicked(self, _btn: Gtk.Button) -> None:
|
||||||
|
if hasattr(self, "_on_reload"):
|
||||||
|
self._on_reload(self._app_id)
|
||||||
|
|
||||||
|
# ─── Connect handlers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def connect_launch(self, callback) -> None:
|
||||||
|
self._on_launch = callback
|
||||||
|
|
||||||
|
def connect_launch_ce(self, callback) -> None:
|
||||||
|
self._on_launch_ce = callback
|
||||||
|
|
||||||
|
def connect_toggle(self, callback) -> None:
|
||||||
|
self._on_toggle = callback
|
||||||
|
|
||||||
|
def connect_reload(self, callback) -> None:
|
||||||
|
self._on_reload = callback
|
||||||
|
|
||||||
|
def connect_pick_table(self, callback) -> None:
|
||||||
|
self._on_pick_table = callback
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Guide / onboarding page."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Adw", "1")
|
||||||
|
|
||||||
|
from gi.repository import Adw, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from vual.i18n import _ # noqa: E402
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from vual.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def build_guide_page(nav_view: Adw.NavigationView, config: Config, open_preferences) -> Adw.NavigationPage:
|
||||||
|
"""Build the guide/onboarding page."""
|
||||||
|
toolbar_view = Adw.ToolbarView()
|
||||||
|
|
||||||
|
header = Adw.HeaderBar()
|
||||||
|
header.add_css_class("flat")
|
||||||
|
toolbar_view.add_top_bar(header)
|
||||||
|
|
||||||
|
scroll = Gtk.ScrolledWindow(vexpand=True)
|
||||||
|
toolbar_view.set_content(scroll)
|
||||||
|
|
||||||
|
clamp = Adw.Clamp(maximum_size=760)
|
||||||
|
scroll.set_child(clamp)
|
||||||
|
|
||||||
|
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
|
||||||
|
content.add_css_class("guide-page")
|
||||||
|
content.set_margin_top(28)
|
||||||
|
content.set_margin_bottom(40)
|
||||||
|
content.set_margin_start(24)
|
||||||
|
content.set_margin_end(24)
|
||||||
|
clamp.set_child(content)
|
||||||
|
|
||||||
|
hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||||
|
hero.add_css_class("guide-hero")
|
||||||
|
content.append(hero)
|
||||||
|
|
||||||
|
eyebrow = Gtk.Label(label=_("Getting Started"), xalign=0)
|
||||||
|
eyebrow.add_css_class("guide-eyebrow")
|
||||||
|
hero.append(eyebrow)
|
||||||
|
|
||||||
|
title = Gtk.Label(label=_("Set up Vual in a couple of minutes"), xalign=0)
|
||||||
|
title.add_css_class("guide-title")
|
||||||
|
title.set_wrap(True)
|
||||||
|
hero.append(title)
|
||||||
|
|
||||||
|
subtitle = Gtk.Label(
|
||||||
|
label=_("Vual links Steam, Proton and Cheat Engine so you can launch the game first and open CE in the right prefix without manual setup every time."),
|
||||||
|
xalign=0,
|
||||||
|
wrap=True,
|
||||||
|
)
|
||||||
|
subtitle.add_css_class("guide-subtitle")
|
||||||
|
hero.append(subtitle)
|
||||||
|
|
||||||
|
prep_card = _build_card(
|
||||||
|
_("Before you start"),
|
||||||
|
_("Make sure the base tools are ready before enabling games."),
|
||||||
|
[
|
||||||
|
_("Install or download Cheat Engine in Settings"),
|
||||||
|
_("Check that the Steam path points to the correct library"),
|
||||||
|
_("Close Steam before changing launch options for many games"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
content.append(prep_card)
|
||||||
|
|
||||||
|
flow_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
|
||||||
|
flow_card.add_css_class("guide-card")
|
||||||
|
content.append(flow_card)
|
||||||
|
|
||||||
|
flow_title = Gtk.Label(label=_("How Vual works"), xalign=0)
|
||||||
|
flow_title.add_css_class("guide-card-title")
|
||||||
|
flow_card.append(flow_title)
|
||||||
|
|
||||||
|
flow_subtitle = Gtk.Label(
|
||||||
|
label=_("The usual workflow is short and always the same."),
|
||||||
|
xalign=0,
|
||||||
|
wrap=True,
|
||||||
|
)
|
||||||
|
flow_subtitle.add_css_class("guide-card-subtitle")
|
||||||
|
flow_card.append(flow_subtitle)
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
(_("Pick a game"), _("Find the game in the library and enable the switch on its tile.")),
|
||||||
|
(_("Start it from Steam"), _("Steam launches the game with protonhax and prepares the correct environment.")),
|
||||||
|
(_("Open Cheat Engine"), _("Use the tile menu to launch CE directly for that running game.")),
|
||||||
|
(_("Optional: attach a table"), _("Bind a .CT file once and Vual will pass it to Cheat Engine automatically.")),
|
||||||
|
]
|
||||||
|
for index, (step_title, step_desc) in enumerate(steps, start=1):
|
||||||
|
flow_card.append(_build_step(index, step_title, step_desc))
|
||||||
|
|
||||||
|
tips_card = _build_card(
|
||||||
|
_("Good to know"),
|
||||||
|
_("A few things save time once the basics are configured."),
|
||||||
|
[
|
||||||
|
_("Use search in the header to filter large libraries quickly"),
|
||||||
|
_("The menu button on each tile contains CE actions and table binding"),
|
||||||
|
_("Window transparency can be toggled in Appearance settings"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
content.append(tips_card)
|
||||||
|
|
||||||
|
actions = Gtk.Box(spacing=12)
|
||||||
|
actions.set_halign(Gtk.Align.CENTER)
|
||||||
|
actions.set_margin_top(12)
|
||||||
|
|
||||||
|
def on_close(_btn):
|
||||||
|
config.guide_shown = True
|
||||||
|
config.save()
|
||||||
|
nav_view.pop()
|
||||||
|
|
||||||
|
def on_preferences(_btn):
|
||||||
|
on_close(_btn)
|
||||||
|
open_preferences()
|
||||||
|
|
||||||
|
prefs_btn = Gtk.Button(label=_("Open Settings"))
|
||||||
|
prefs_btn.add_css_class("pill")
|
||||||
|
prefs_btn.connect("clicked", on_preferences)
|
||||||
|
actions.append(prefs_btn)
|
||||||
|
|
||||||
|
start_btn = Gtk.Button(label=_("Open Library"))
|
||||||
|
start_btn.add_css_class("suggested-action")
|
||||||
|
start_btn.add_css_class("pill")
|
||||||
|
start_btn.connect("clicked", on_close)
|
||||||
|
actions.append(start_btn)
|
||||||
|
content.append(actions)
|
||||||
|
|
||||||
|
return Adw.NavigationPage(child=toolbar_view, title=_("Welcome to Vual"), tag="guide")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_card(title: str, subtitle: str, items: list[str]) -> Gtk.Box:
|
||||||
|
"""Build a guide card with bullet rows."""
|
||||||
|
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||||
|
card.add_css_class("guide-card")
|
||||||
|
|
||||||
|
title_label = Gtk.Label(label=title, xalign=0)
|
||||||
|
title_label.add_css_class("guide-card-title")
|
||||||
|
card.append(title_label)
|
||||||
|
|
||||||
|
subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
|
||||||
|
subtitle_label.add_css_class("guide-card-subtitle")
|
||||||
|
card.append(subtitle_label)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
row = Gtk.Box(spacing=10)
|
||||||
|
row.add_css_class("guide-list-row")
|
||||||
|
|
||||||
|
bullet = Gtk.Label(label="\u2022")
|
||||||
|
bullet.add_css_class("guide-bullet")
|
||||||
|
row.append(bullet)
|
||||||
|
|
||||||
|
item_label = Gtk.Label(label=item, xalign=0, wrap=True)
|
||||||
|
item_label.add_css_class("guide-list-text")
|
||||||
|
row.append(item_label)
|
||||||
|
card.append(row)
|
||||||
|
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def _build_step(index: int, title: str, description: str) -> Gtk.Box:
|
||||||
|
"""Build a single step row for the guide."""
|
||||||
|
row = Gtk.Box(spacing=14)
|
||||||
|
row.add_css_class("guide-step")
|
||||||
|
|
||||||
|
badge = Gtk.Label(label=str(index))
|
||||||
|
badge.add_css_class("guide-step-badge")
|
||||||
|
badge.set_size_request(34, 34)
|
||||||
|
badge.set_valign(Gtk.Align.START)
|
||||||
|
row.append(badge)
|
||||||
|
|
||||||
|
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
|
||||||
|
|
||||||
|
title_label = Gtk.Label(label=title, xalign=0)
|
||||||
|
title_label.add_css_class("guide-step-title")
|
||||||
|
text_box.append(title_label)
|
||||||
|
|
||||||
|
desc_label = Gtk.Label(label=description, xalign=0, wrap=True)
|
||||||
|
desc_label.add_css_class("guide-step-description")
|
||||||
|
text_box.append(desc_label)
|
||||||
|
|
||||||
|
row.append(text_box)
|
||||||
|
return row
|
||||||
@@ -1,31 +1,24 @@
|
|||||||
"""Main games grid page.
|
"""Main games grid page."""
|
||||||
|
|
||||||
Clean rewrite using Gtk.FlowBox + simple Gtk.Overlay tiles.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Adw", "1")
|
gi.require_version("Adw", "1")
|
||||||
gi.require_version("Gdk", "4.0")
|
|
||||||
gi.require_version("GdkPixbuf", "2.0")
|
|
||||||
gi.require_version("Pango", "1.0")
|
|
||||||
|
|
||||||
from gi.repository import Adw, Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango
|
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
import requests
|
from vual import protonhax, steam, tables # noqa: E402
|
||||||
|
from vual.config import TILE_SIZES, Config # noqa: E402
|
||||||
from vual import protonhax, steam, tables
|
from vual.i18n import _ # noqa: E402
|
||||||
from vual.config import TILE_SIZES, Config
|
from vual.ui.covers import cache_path, download_cover, load_pixbuf # noqa: E402
|
||||||
from vual.i18n import _
|
from vual.ui.game_tile import COVER_RATIO, GameTile # noqa: E402
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from vual.window import VualWindow
|
from vual.window import VualWindow
|
||||||
@@ -35,373 +28,8 @@ if TYPE_CHECKING:
|
|||||||
# Constants
|
# Constants
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
COVER_RATIO = 1.5 # Height = Width * 1.5 (portrait)
|
GRID_SPACING = 12
|
||||||
GRID_SPACING = 12 # Spacing between tiles
|
GRID_MARGIN = 16
|
||||||
GRID_MARGIN = 16 # Margin around grid
|
|
||||||
|
|
||||||
# Steam cover URLs
|
|
||||||
COVER_URL = "https://steamcdn-a.akamaihd.net/steam/apps/{}/library_600x900_2x.jpg"
|
|
||||||
COVER_FALLBACK = "https://steamcdn-a.akamaihd.net/steam/apps/{}/header.jpg"
|
|
||||||
|
|
||||||
# Cache directory
|
|
||||||
CACHE_DIR = Path(GLib.get_user_cache_dir()) / "vual" / "covers"
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# Cover loading utilities
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def _cache_path(app_id: str) -> Path:
|
|
||||||
"""Get cache path for cover image."""
|
|
||||||
return CACHE_DIR / f"{app_id}.jpg"
|
|
||||||
|
|
||||||
|
|
||||||
def _download_cover(app_id: str) -> Path | None:
|
|
||||||
"""Download cover from Steam CDN. Returns cache path or None."""
|
|
||||||
cache = _cache_path(app_id)
|
|
||||||
if cache.exists():
|
|
||||||
return cache
|
|
||||||
|
|
||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for url_template in (COVER_URL, COVER_FALLBACK):
|
|
||||||
url = url_template.format(app_id)
|
|
||||||
try:
|
|
||||||
resp = requests.get(url, headers={"User-Agent": "Vual/1.0"}, timeout=10)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
cache.write_bytes(resp.content)
|
|
||||||
return cache
|
|
||||||
except requests.RequestException:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_pixbuf(path: Path, width: int, height: int) -> GdkPixbuf.Pixbuf | None:
|
|
||||||
"""Load and scale pixbuf from file.
|
|
||||||
|
|
||||||
Handles landscape images by compositing them onto a blurred background.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
|
||||||
except GLib.Error:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ow, oh = original.get_width(), original.get_height()
|
|
||||||
if ow < 1 or oh < 1:
|
|
||||||
return None
|
|
||||||
|
|
||||||
aspect = ow / oh
|
|
||||||
|
|
||||||
# If image is landscape (wider than 4:3), create composite with blur
|
|
||||||
if aspect > 1.33:
|
|
||||||
return _create_landscape_composite(original, ow, oh, width, height)
|
|
||||||
|
|
||||||
# Portrait or square: scale to fill
|
|
||||||
return original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_landscape_composite(
|
|
||||||
original: GdkPixbuf.Pixbuf,
|
|
||||||
ow: int, oh: int,
|
|
||||||
width: int, height: int
|
|
||||||
) -> GdkPixbuf.Pixbuf:
|
|
||||||
"""Create a portrait tile from a landscape image.
|
|
||||||
|
|
||||||
Composites the original image centered on a scaled/blurred background.
|
|
||||||
"""
|
|
||||||
# Create background: scale original to fill tile (will be cropped/stretched)
|
|
||||||
bg = original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
|
||||||
|
|
||||||
# Apply simple darkening to background (simulates blur effect)
|
|
||||||
# We darken by compositing with a semi-transparent black
|
|
||||||
dark = GdkPixbuf.Pixbuf.new(
|
|
||||||
GdkPixbuf.Colorspace.RGB, True, 8, width, height
|
|
||||||
)
|
|
||||||
dark.fill(0x00000099) # Black with ~60% opacity
|
|
||||||
dark.composite(
|
|
||||||
bg, 0, 0, width, height,
|
|
||||||
0, 0, 1.0, 1.0,
|
|
||||||
GdkPixbuf.InterpType.NEAREST, 180
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scale original to fit width while preserving aspect ratio
|
|
||||||
scale = width / ow
|
|
||||||
scaled_w = width
|
|
||||||
scaled_h = int(oh * scale)
|
|
||||||
|
|
||||||
scaled = original.scale_simple(scaled_w, scaled_h, GdkPixbuf.InterpType.BILINEAR)
|
|
||||||
|
|
||||||
# Center vertically
|
|
||||||
y_offset = (height - scaled_h) // 2
|
|
||||||
|
|
||||||
# Composite scaled image onto darkened background
|
|
||||||
scaled.composite(
|
|
||||||
bg,
|
|
||||||
0, y_offset, # dest x, y
|
|
||||||
scaled_w, scaled_h, # dest width, height
|
|
||||||
0, y_offset, # offset x, y
|
|
||||||
1.0, 1.0, # scale x, y
|
|
||||||
GdkPixbuf.InterpType.BILINEAR,
|
|
||||||
255 # full opacity
|
|
||||||
)
|
|
||||||
|
|
||||||
return bg
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# GameTile — single game tile widget
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class GameTile(Gtk.Overlay):
|
|
||||||
"""A single game tile with cover image and controls overlay."""
|
|
||||||
|
|
||||||
__gtype_name__ = "VualGameTile"
|
|
||||||
|
|
||||||
def __init__(self, app_id: str, name: str, is_active: bool, tile_width: int):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self._app_id = app_id
|
|
||||||
self._name = name
|
|
||||||
self._is_active = is_active
|
|
||||||
self._is_running = False
|
|
||||||
self._width = tile_width
|
|
||||||
self._height = int(tile_width * COVER_RATIO)
|
|
||||||
|
|
||||||
# Fixed size container
|
|
||||||
self.set_size_request(self._width, self._height)
|
|
||||||
self.add_css_class("vual-tile")
|
|
||||||
self.set_overflow(Gtk.Overflow.HIDDEN)
|
|
||||||
|
|
||||||
# Cover image (fills the tile)
|
|
||||||
self._picture = Gtk.Picture()
|
|
||||||
self._picture.set_content_fit(Gtk.ContentFit.COVER)
|
|
||||||
self._picture.set_size_request(self._width, self._height)
|
|
||||||
self._picture.add_css_class("skeleton")
|
|
||||||
self.set_child(self._picture)
|
|
||||||
|
|
||||||
# Bottom info overlay
|
|
||||||
self._build_info_box()
|
|
||||||
|
|
||||||
# Top-left reload button
|
|
||||||
self._build_reload_button()
|
|
||||||
|
|
||||||
# Top-right running badge
|
|
||||||
self._build_running_badge()
|
|
||||||
|
|
||||||
def _build_info_box(self) -> None:
|
|
||||||
"""Create bottom info panel with name, buttons, and switch."""
|
|
||||||
info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
|
||||||
info.add_css_class("vual-tile-info")
|
|
||||||
info.set_valign(Gtk.Align.END)
|
|
||||||
info.set_halign(Gtk.Align.FILL)
|
|
||||||
|
|
||||||
# Game name
|
|
||||||
label = Gtk.Label(label=self._name)
|
|
||||||
label.set_ellipsize(Pango.EllipsizeMode.END)
|
|
||||||
label.set_max_width_chars(15)
|
|
||||||
label.set_halign(Gtk.Align.START)
|
|
||||||
label.add_css_class("heading")
|
|
||||||
info.append(label)
|
|
||||||
|
|
||||||
# Controls row: [Play] [CE] ---- [Switch]
|
|
||||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
||||||
row.set_halign(Gtk.Align.FILL)
|
|
||||||
|
|
||||||
# Play button
|
|
||||||
btn_play = Gtk.Button()
|
|
||||||
btn_play.set_icon_name("media-playback-start-symbolic")
|
|
||||||
btn_play.add_css_class("flat")
|
|
||||||
btn_play.add_css_class("tile-btn")
|
|
||||||
btn_play.set_tooltip_text(_("Launch game"))
|
|
||||||
btn_play.connect("clicked", self._on_play_clicked)
|
|
||||||
row.append(btn_play)
|
|
||||||
|
|
||||||
# CE button with table menu
|
|
||||||
self._btn_ce = Gtk.MenuButton()
|
|
||||||
self._btn_ce.set_icon_name("open-menu-symbolic")
|
|
||||||
self._btn_ce.add_css_class("flat")
|
|
||||||
self._btn_ce.add_css_class("tile-btn")
|
|
||||||
self._update_ce_tooltip()
|
|
||||||
|
|
||||||
ce_menu = Gio.Menu()
|
|
||||||
ce_menu.append(_("Launch Cheat Engine"), "tile.launch-ce")
|
|
||||||
ce_menu.append(_("Assign table…"), "tile.assign-table")
|
|
||||||
ce_menu.append(_("Remove table"), "tile.remove-table")
|
|
||||||
|
|
||||||
pop = Gtk.PopoverMenu.new_from_model(ce_menu)
|
|
||||||
self._btn_ce.set_popover(pop)
|
|
||||||
|
|
||||||
# Actions
|
|
||||||
group = Gio.SimpleActionGroup()
|
|
||||||
|
|
||||||
act_launch = Gio.SimpleAction.new("launch-ce", None)
|
|
||||||
act_launch.connect("activate", lambda *_: self._on_ce_clicked(None))
|
|
||||||
group.add_action(act_launch)
|
|
||||||
|
|
||||||
act_assign = Gio.SimpleAction.new("assign-table", None)
|
|
||||||
act_assign.connect("activate", lambda *_: self._on_assign_table())
|
|
||||||
group.add_action(act_assign)
|
|
||||||
|
|
||||||
self._act_remove = Gio.SimpleAction.new("remove-table", None)
|
|
||||||
self._act_remove.connect("activate", lambda *_: self._on_remove_table())
|
|
||||||
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
|
|
||||||
group.add_action(self._act_remove)
|
|
||||||
|
|
||||||
self.insert_action_group("tile", group)
|
|
||||||
row.append(self._btn_ce)
|
|
||||||
|
|
||||||
# Spacer
|
|
||||||
spacer = Gtk.Box()
|
|
||||||
spacer.set_hexpand(True)
|
|
||||||
row.append(spacer)
|
|
||||||
|
|
||||||
# Active switch
|
|
||||||
self._switch = Gtk.Switch()
|
|
||||||
self._switch.set_valign(Gtk.Align.CENTER)
|
|
||||||
self._switch.set_active(self._is_active)
|
|
||||||
self._switch.connect("state-set", self._on_switch_toggled)
|
|
||||||
row.append(self._switch)
|
|
||||||
|
|
||||||
info.append(row)
|
|
||||||
self.add_overlay(info)
|
|
||||||
|
|
||||||
def _build_reload_button(self) -> None:
|
|
||||||
"""Create reload button in top-left corner."""
|
|
||||||
btn = Gtk.Button()
|
|
||||||
btn.set_icon_name("view-refresh-symbolic")
|
|
||||||
btn.add_css_class("flat")
|
|
||||||
btn.add_css_class("vual-reload-btn")
|
|
||||||
btn.set_valign(Gtk.Align.START)
|
|
||||||
btn.set_halign(Gtk.Align.START)
|
|
||||||
btn.set_margin_top(6)
|
|
||||||
btn.set_margin_start(6)
|
|
||||||
btn.set_tooltip_text(_("Refresh cover"))
|
|
||||||
btn.connect("clicked", self._on_reload_clicked)
|
|
||||||
self.add_overlay(btn)
|
|
||||||
|
|
||||||
def _build_running_badge(self) -> None:
|
|
||||||
"""Create running badge in top-right corner."""
|
|
||||||
self._badge = Gtk.Box()
|
|
||||||
self._badge.add_css_class("vual-badge")
|
|
||||||
self._badge.set_valign(Gtk.Align.START)
|
|
||||||
self._badge.set_halign(Gtk.Align.END)
|
|
||||||
self._badge.set_margin_top(6)
|
|
||||||
self._badge.set_margin_end(6)
|
|
||||||
self._badge.set_visible(False)
|
|
||||||
self.add_overlay(self._badge)
|
|
||||||
|
|
||||||
# ─── Properties ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@property
|
|
||||||
def app_id(self) -> str:
|
|
||||||
return self._app_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_active(self) -> bool:
|
|
||||||
return self._is_active
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_running(self) -> bool:
|
|
||||||
return self._is_running
|
|
||||||
|
|
||||||
# ─── Setters ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def set_active_silent(self, active: bool) -> None:
|
|
||||||
"""Set switch without triggering callback."""
|
|
||||||
self._is_active = active
|
|
||||||
self._switch.handler_block_by_func(self._on_switch_toggled)
|
|
||||||
self._switch.set_active(active)
|
|
||||||
self._switch.handler_unblock_by_func(self._on_switch_toggled)
|
|
||||||
|
|
||||||
def set_running(self, running: bool) -> None:
|
|
||||||
"""Update running badge visibility."""
|
|
||||||
self._is_running = running
|
|
||||||
self._badge.set_visible(running)
|
|
||||||
|
|
||||||
def set_cover(self, pixbuf: GdkPixbuf.Pixbuf | None) -> None:
|
|
||||||
"""Set cover image from pixbuf."""
|
|
||||||
self._picture.remove_css_class("skeleton")
|
|
||||||
if pixbuf:
|
|
||||||
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
|
||||||
self._picture.set_paintable(texture)
|
|
||||||
else:
|
|
||||||
self._picture.set_paintable(None)
|
|
||||||
|
|
||||||
def resize(self, width: int) -> None:
|
|
||||||
"""Resize tile to new width."""
|
|
||||||
self._width = width
|
|
||||||
self._height = int(width * COVER_RATIO)
|
|
||||||
self.set_size_request(self._width, self._height)
|
|
||||||
self._picture.set_size_request(self._width, self._height)
|
|
||||||
|
|
||||||
# ─── Callbacks ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _on_play_clicked(self, _btn: Gtk.Button) -> None:
|
|
||||||
"""Emit play signal."""
|
|
||||||
if hasattr(self, "_on_launch"):
|
|
||||||
self._on_launch(self._app_id, self._name)
|
|
||||||
|
|
||||||
def _on_ce_clicked(self, _btn: Gtk.Button | None) -> None:
|
|
||||||
"""Emit CE signal."""
|
|
||||||
if hasattr(self, "_on_launch_ce"):
|
|
||||||
self._on_launch_ce(self._app_id, self._name)
|
|
||||||
|
|
||||||
def _on_assign_table(self) -> None:
|
|
||||||
"""Open file chooser to assign a .CT table."""
|
|
||||||
if hasattr(self, "_on_pick_table"):
|
|
||||||
self._on_pick_table(self._app_id)
|
|
||||||
|
|
||||||
def _on_remove_table(self) -> None:
|
|
||||||
"""Remove the bound table."""
|
|
||||||
tables.unbind(self._app_id)
|
|
||||||
self._act_remove.set_enabled(False)
|
|
||||||
self._update_ce_tooltip()
|
|
||||||
|
|
||||||
def _update_ce_tooltip(self) -> None:
|
|
||||||
name = tables.get_table_name(self._app_id)
|
|
||||||
if name:
|
|
||||||
self._btn_ce.set_tooltip_text(f"CE: {name}")
|
|
||||||
else:
|
|
||||||
self._btn_ce.set_tooltip_text(_("Launch Cheat Engine"))
|
|
||||||
|
|
||||||
def mark_table_bound(self) -> None:
|
|
||||||
"""Refresh table indicator after binding."""
|
|
||||||
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
|
|
||||||
self._update_ce_tooltip()
|
|
||||||
|
|
||||||
def _on_switch_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
|
|
||||||
"""Handle activation toggle."""
|
|
||||||
self._is_active = state
|
|
||||||
if hasattr(self, "_on_toggle"):
|
|
||||||
self._on_toggle(self._app_id, state)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _on_reload_clicked(self, _btn: Gtk.Button) -> None:
|
|
||||||
"""Emit reload signal."""
|
|
||||||
if hasattr(self, "_on_reload"):
|
|
||||||
self._on_reload(self._app_id)
|
|
||||||
|
|
||||||
# ─── Connect handlers ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def connect_launch(self, callback) -> None:
|
|
||||||
self._on_launch = callback
|
|
||||||
|
|
||||||
def connect_launch_ce(self, callback) -> None:
|
|
||||||
self._on_launch_ce = callback
|
|
||||||
|
|
||||||
def connect_toggle(self, callback) -> None:
|
|
||||||
self._on_toggle = callback
|
|
||||||
|
|
||||||
def connect_reload(self, callback) -> None:
|
|
||||||
self._on_reload = callback
|
|
||||||
|
|
||||||
def connect_pick_table(self, callback) -> None:
|
|
||||||
self._on_pick_table = callback
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -634,9 +262,9 @@ class MainPage(Adw.Bin):
|
|||||||
height = tile._height
|
height = tile._height
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
path = _download_cover(app_id)
|
path = download_cover(app_id)
|
||||||
if path:
|
if path:
|
||||||
pixbuf = _load_pixbuf(path, width, height)
|
pixbuf = load_pixbuf(path, width, height)
|
||||||
GLib.idle_add(tile.set_cover, pixbuf)
|
GLib.idle_add(tile.set_cover, pixbuf)
|
||||||
else:
|
else:
|
||||||
GLib.idle_add(tile.set_cover, None)
|
GLib.idle_add(tile.set_cover, None)
|
||||||
@@ -650,7 +278,7 @@ class MainPage(Adw.Bin):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Delete cached cover
|
# Delete cached cover
|
||||||
cache = _cache_path(app_id)
|
cache = cache_path(app_id)
|
||||||
if cache.exists():
|
if cache.exists():
|
||||||
cache.unlink()
|
cache.unlink()
|
||||||
|
|
||||||
@@ -730,6 +358,7 @@ class MainPage(Adw.Bin):
|
|||||||
"""Open file chooser to select a .CT table for *app_id*."""
|
"""Open file chooser to select a .CT table for *app_id*."""
|
||||||
dialog = Gtk.FileDialog()
|
dialog = Gtk.FileDialog()
|
||||||
dialog.set_title(_("Select Cheat Engine table"))
|
dialog.set_title(_("Select Cheat Engine table"))
|
||||||
|
dialog.set_initial_folder(Gio.File.new_for_path(str(tables.TABLES_DIR)))
|
||||||
|
|
||||||
f = Gtk.FileFilter()
|
f = Gtk.FileFilter()
|
||||||
f.set_name(_("CE tables (*.CT)"))
|
f.set_name(_("CE tables (*.CT)"))
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Appearance preferences page — theme, language, grid, Wine theme."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Adw", "1")
|
||||||
|
|
||||||
|
from gi.repository import Adw, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from vual import wine_theme # noqa: E402
|
||||||
|
from vual.i18n import _ # noqa: E402
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from vual.ui.preferences import PreferencesWindow
|
||||||
|
|
||||||
|
|
||||||
|
def build_appearance_page(win: PreferencesWindow) -> Adw.PreferencesPage:
|
||||||
|
"""Build the Appearance preferences page."""
|
||||||
|
cfg = win._config
|
||||||
|
page = Adw.PreferencesPage(
|
||||||
|
title=_("Appearance"),
|
||||||
|
icon_name="preferences-desktop-appearance-symbolic",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Theme group ──────────────────────────────────────────────
|
||||||
|
theme_group = Adw.PreferencesGroup(title=_("Theme"))
|
||||||
|
page.add(theme_group)
|
||||||
|
|
||||||
|
theme_row = Adw.ComboRow(
|
||||||
|
title=_("Color scheme"),
|
||||||
|
subtitle=_("Light, dark, or system"),
|
||||||
|
)
|
||||||
|
model = Gtk.StringList.new([_("System"), _("Light"), _("Dark")])
|
||||||
|
theme_row.set_model(model)
|
||||||
|
idx = {"system": 0, "light": 1, "dark": 2}.get(cfg.color_scheme, 0)
|
||||||
|
theme_row.set_selected(idx)
|
||||||
|
theme_row.connect("notify::selected", lambda row, _: _on_theme_changed(win, row))
|
||||||
|
theme_group.add(theme_row)
|
||||||
|
|
||||||
|
# Language selection
|
||||||
|
lang_row = Adw.ComboRow(
|
||||||
|
title=_("Language"),
|
||||||
|
subtitle=_("Application interface language"),
|
||||||
|
)
|
||||||
|
lang_model = Gtk.StringList.new([_("System"), _("English"), _("Русский")])
|
||||||
|
lang_row.set_model(lang_model)
|
||||||
|
lang_idx = {"system": 0, "en": 1, "ru": 2}.get(cfg.app_language, 0)
|
||||||
|
lang_row.set_selected(lang_idx)
|
||||||
|
lang_row.connect("notify::selected", lambda row, _: _on_app_lang_changed(win, row))
|
||||||
|
theme_group.add(lang_row)
|
||||||
|
|
||||||
|
# Transparent window toggle
|
||||||
|
transparent_row = Adw.SwitchRow(
|
||||||
|
title=_("Transparent window"),
|
||||||
|
subtitle=_("Semi-transparent background"),
|
||||||
|
)
|
||||||
|
transparent_row.set_active(cfg.transparent_window)
|
||||||
|
transparent_row.connect("notify::active", lambda row, _: _on_transparent_changed(win, row))
|
||||||
|
theme_group.add(transparent_row)
|
||||||
|
|
||||||
|
# ── Grid group ───────────────────────────────────────────────
|
||||||
|
grid_group = Adw.PreferencesGroup(
|
||||||
|
title=_("Grid"),
|
||||||
|
description=_("Game display settings"),
|
||||||
|
)
|
||||||
|
page.add(grid_group)
|
||||||
|
|
||||||
|
tile_row = Adw.ComboRow(
|
||||||
|
title=_("Tile size"),
|
||||||
|
subtitle=_("Cover size in library"),
|
||||||
|
)
|
||||||
|
tile_model = Gtk.StringList.new([_("Small (120px)"), _("Medium (150px)"), _("Large (180px)")])
|
||||||
|
tile_row.set_model(tile_model)
|
||||||
|
tile_idx = {"small": 0, "medium": 1, "large": 2}.get(cfg.tile_size, 1)
|
||||||
|
tile_row.set_selected(tile_idx)
|
||||||
|
tile_row.connect("notify::selected", lambda row, _: _on_tile_size_changed(win, row))
|
||||||
|
grid_group.add(tile_row)
|
||||||
|
|
||||||
|
sort_row = Adw.ComboRow(
|
||||||
|
title=_("Sort"),
|
||||||
|
subtitle=_("Default game order"),
|
||||||
|
)
|
||||||
|
sort_model = Gtk.StringList.new([_("By name"), _("By status")])
|
||||||
|
sort_row.set_model(sort_model)
|
||||||
|
sort_idx = {"name": 0, "status": 1}.get(cfg.sort_by, 0)
|
||||||
|
sort_row.set_selected(sort_idx)
|
||||||
|
sort_row.connect("notify::selected", lambda row, _: _on_sort_changed(win, row))
|
||||||
|
grid_group.add(sort_row)
|
||||||
|
|
||||||
|
# ── Wine theme group ─────────────────────────────────────────
|
||||||
|
wine_group = Adw.PreferencesGroup(
|
||||||
|
title=_("Wine Theme"),
|
||||||
|
description=_("Color scheme for Cheat Engine and other Wine apps"),
|
||||||
|
)
|
||||||
|
page.add(wine_group)
|
||||||
|
|
||||||
|
wine_theme_row = Adw.ComboRow(
|
||||||
|
title=_("Color scheme"),
|
||||||
|
subtitle=_("Applied to all Proton prefixes"),
|
||||||
|
)
|
||||||
|
wine_model = Gtk.StringList.new([_("System"), _("Dark"), _("Light")])
|
||||||
|
wine_theme_row.set_model(wine_model)
|
||||||
|
wine_idx = {"system": 0, "dark": 1, "light": 2}.get(cfg.wine_theme, 0)
|
||||||
|
wine_theme_row.set_selected(wine_idx)
|
||||||
|
|
||||||
|
wine_refresh_btn = Gtk.Button(
|
||||||
|
icon_name="view-refresh-symbolic",
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["flat"],
|
||||||
|
tooltip_text=_("Reapply theme"),
|
||||||
|
)
|
||||||
|
|
||||||
|
wine_theme_row.connect(
|
||||||
|
"notify::selected",
|
||||||
|
lambda row, _: _on_wine_theme_changed(win, row, wine_refresh_btn),
|
||||||
|
)
|
||||||
|
wine_refresh_btn.connect(
|
||||||
|
"clicked",
|
||||||
|
lambda _: _on_wine_theme_refresh(win, wine_theme_row, wine_refresh_btn),
|
||||||
|
)
|
||||||
|
wine_theme_row.add_suffix(wine_refresh_btn)
|
||||||
|
wine_group.add(wine_theme_row)
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_theme_changed(win: PreferencesWindow, row: Adw.ComboRow) -> None:
|
||||||
|
schemes = ["system", "light", "dark"]
|
||||||
|
win._config.color_scheme = schemes[row.get_selected()]
|
||||||
|
win._save()
|
||||||
|
style = Adw.StyleManager.get_default()
|
||||||
|
scheme_map = {
|
||||||
|
"light": Adw.ColorScheme.FORCE_LIGHT,
|
||||||
|
"dark": Adw.ColorScheme.FORCE_DARK,
|
||||||
|
"system": Adw.ColorScheme.DEFAULT,
|
||||||
|
}
|
||||||
|
style.set_color_scheme(scheme_map.get(win._config.color_scheme, Adw.ColorScheme.DEFAULT))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_tile_size_changed(win: PreferencesWindow, row: Adw.ComboRow) -> None:
|
||||||
|
sizes = ["small", "medium", "large"]
|
||||||
|
win._config.tile_size = sizes[row.get_selected()]
|
||||||
|
win._save()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_sort_changed(win: PreferencesWindow, row: Adw.ComboRow) -> None:
|
||||||
|
sorts = ["name", "status"]
|
||||||
|
win._config.sort_by = sorts[row.get_selected()]
|
||||||
|
win._save()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_app_lang_changed(win: PreferencesWindow, row: Adw.ComboRow) -> None:
|
||||||
|
langs = ["system", "en", "ru"]
|
||||||
|
win._config.app_language = langs[row.get_selected()]
|
||||||
|
win._save()
|
||||||
|
win._show_toast(_("Restart app to apply language"))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_transparent_changed(win: PreferencesWindow, row: Adw.SwitchRow) -> None:
|
||||||
|
win._config.transparent_window = row.get_active()
|
||||||
|
win._save()
|
||||||
|
if win._parent:
|
||||||
|
win._parent.apply_transparency()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_system_theme() -> str:
|
||||||
|
style = Adw.StyleManager.get_default()
|
||||||
|
return "dark" if style.get_dark() else "light"
|
||||||
|
|
||||||
|
|
||||||
|
def _on_wine_theme_changed(
|
||||||
|
win: PreferencesWindow, row: Adw.ComboRow, refresh_btn: Gtk.Button,
|
||||||
|
) -> None:
|
||||||
|
themes = ["system", "dark", "light"]
|
||||||
|
theme = themes[row.get_selected()]
|
||||||
|
win._config.wine_theme = theme
|
||||||
|
win._save()
|
||||||
|
|
||||||
|
actual_theme = _get_system_theme() if theme == "system" else theme
|
||||||
|
|
||||||
|
row.set_sensitive(False)
|
||||||
|
refresh_btn.set_sensitive(False)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
steamapps = win._config.steamapps_path
|
||||||
|
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
|
||||||
|
GLib.idle_add(_on_wine_applied, win, row, refresh_btn, success, failed)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_wine_theme_refresh(
|
||||||
|
win: PreferencesWindow, row: Adw.ComboRow, refresh_btn: Gtk.Button,
|
||||||
|
) -> None:
|
||||||
|
theme = win._config.wine_theme
|
||||||
|
actual_theme = _get_system_theme() if theme == "system" else theme
|
||||||
|
|
||||||
|
row.set_sensitive(False)
|
||||||
|
refresh_btn.set_sensitive(False)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
steamapps = win._config.steamapps_path
|
||||||
|
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
|
||||||
|
GLib.idle_add(_on_wine_applied, win, row, refresh_btn, success, failed)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_wine_applied(
|
||||||
|
win: PreferencesWindow, row: Adw.ComboRow, refresh_btn: Gtk.Button,
|
||||||
|
success: int, failed: int,
|
||||||
|
) -> None:
|
||||||
|
row.set_sensitive(True)
|
||||||
|
refresh_btn.set_sensitive(True)
|
||||||
|
if success > 0:
|
||||||
|
win._show_toast(_("Theme applied to %d prefixes") % success)
|
||||||
|
elif failed > 0:
|
||||||
|
win._show_toast(_("Error applying to %d prefixes") % failed)
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Cheat Engine preferences page — status, download, extract, protonhax, locale."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Adw", "1")
|
||||||
|
|
||||||
|
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from vual import cheatengine, protonhax # noqa: E402
|
||||||
|
from vual.i18n import _ # noqa: E402
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from vual.ui.preferences import PreferencesWindow
|
||||||
|
|
||||||
|
|
||||||
|
def build_ce_page(win: PreferencesWindow) -> Adw.PreferencesPage:
|
||||||
|
"""Build the Cheat Engine preferences page."""
|
||||||
|
cfg = win._config
|
||||||
|
page = Adw.PreferencesPage(
|
||||||
|
title="Cheat Engine",
|
||||||
|
icon_name="applications-games-symbolic",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Status group ─────────────────────────────────────────────
|
||||||
|
status_group = Adw.PreferencesGroup(title=_("Status"))
|
||||||
|
page.add(status_group)
|
||||||
|
|
||||||
|
ce_status_row = Adw.ActionRow(title="Cheat Engine", subtitle=_("Checking..."))
|
||||||
|
ce_status_row.set_activatable(False)
|
||||||
|
status_group.add(ce_status_row)
|
||||||
|
|
||||||
|
ph_status_row = Adw.ActionRow(title="protonhax", subtitle=_("Checking..."))
|
||||||
|
ph_status_row.set_activatable(False)
|
||||||
|
status_group.add(ph_status_row)
|
||||||
|
|
||||||
|
# ── Installation group ───────────────────────────────────────
|
||||||
|
install_group = Adw.PreferencesGroup(
|
||||||
|
title=_("Installation"),
|
||||||
|
description=_("Cheat Engine setup and download"),
|
||||||
|
)
|
||||||
|
page.add(install_group)
|
||||||
|
|
||||||
|
ce_path_row = Adw.EntryRow(title=_("Executable"))
|
||||||
|
ce_path_row.set_text(cfg.ce_executable)
|
||||||
|
ce_path_row.connect("changed", lambda row: _on_ce_path_changed(win, row, state))
|
||||||
|
|
||||||
|
browse_btn = Gtk.Button(
|
||||||
|
icon_name="document-open-symbolic",
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["flat"],
|
||||||
|
tooltip_text=_("Choose file"),
|
||||||
|
)
|
||||||
|
browse_btn.connect("clicked", lambda _: _on_browse_ce(win, ce_path_row, state))
|
||||||
|
ce_path_row.add_suffix(browse_btn)
|
||||||
|
install_group.add(ce_path_row)
|
||||||
|
|
||||||
|
dl_row = Adw.ActionRow(title=_("Latest version"), subtitle=_("Click Check"))
|
||||||
|
check_btn = Gtk.Button(label=_("Check"), valign=Gtk.Align.CENTER)
|
||||||
|
check_btn.connect("clicked", lambda _: _on_check_release(win, dl_row, check_btn, state))
|
||||||
|
dl_row.add_suffix(check_btn)
|
||||||
|
install_group.add(dl_row)
|
||||||
|
|
||||||
|
progress_row = Adw.ActionRow(title=_("Download"), subtitle="", visible=False)
|
||||||
|
progress = Gtk.ProgressBar(valign=Gtk.Align.CENTER, show_text=True)
|
||||||
|
progress.set_size_request(150, -1)
|
||||||
|
progress_row.add_suffix(progress)
|
||||||
|
install_group.add(progress_row)
|
||||||
|
|
||||||
|
# ── protonhax group ──────────────────────────────────────────
|
||||||
|
ph_group = Adw.PreferencesGroup(
|
||||||
|
title="protonhax",
|
||||||
|
description=_("Required for attaching to Proton games"),
|
||||||
|
)
|
||||||
|
page.add(ph_group)
|
||||||
|
|
||||||
|
ph_row = Adw.ActionRow(
|
||||||
|
title=_("Install or update"),
|
||||||
|
subtitle=_("Downloads latest version from GitHub"),
|
||||||
|
)
|
||||||
|
ph_btn = Gtk.Button(label=_("Install"), valign=Gtk.Align.CENTER)
|
||||||
|
ph_btn.connect("clicked", lambda _: _on_install_protonhax(win, state))
|
||||||
|
ph_row.add_suffix(ph_btn)
|
||||||
|
ph_group.add(ph_row)
|
||||||
|
|
||||||
|
# ── Language group ───────────────────────────────────────────
|
||||||
|
lang_group = Adw.PreferencesGroup(
|
||||||
|
title=_("Language"),
|
||||||
|
description=_("Cheat Engine interface language"),
|
||||||
|
)
|
||||||
|
page.add(lang_group)
|
||||||
|
|
||||||
|
lang_row = Adw.ComboRow(title=_("Interface"), subtitle=_("Requires CE restart"))
|
||||||
|
lang_model = Gtk.StringList.new([_("System"), _("Russian")])
|
||||||
|
lang_row.set_model(lang_model)
|
||||||
|
lang_idx = {"system": 0, "ru_RU": 1}.get(cfg.ce_language, 0)
|
||||||
|
lang_row.set_selected(lang_idx)
|
||||||
|
lang_row.connect("notify::selected", lambda row, _: _on_lang_changed(win, row))
|
||||||
|
lang_group.add(lang_row)
|
||||||
|
|
||||||
|
loc_row = Adw.ActionRow(title=_("Russian localization"), subtitle=_("Checking..."))
|
||||||
|
loc_btn = Gtk.Button(label=_("Install"), valign=Gtk.Align.CENTER)
|
||||||
|
loc_btn.connect("clicked", lambda _: _on_install_localization(win, loc_row, loc_btn))
|
||||||
|
loc_row.add_suffix(loc_btn)
|
||||||
|
lang_group.add(loc_row)
|
||||||
|
|
||||||
|
# ── Debug group ──────────────────────────────────────────────
|
||||||
|
debug_group = Adw.PreferencesGroup(title=_("Debug"))
|
||||||
|
page.add(debug_group)
|
||||||
|
|
||||||
|
test_row = Adw.ActionRow(
|
||||||
|
title=_("Test launch"),
|
||||||
|
subtitle=_("Launch CE via Wine (without game binding)"),
|
||||||
|
)
|
||||||
|
test_btn = Gtk.Button(
|
||||||
|
label=_("Launch"),
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["suggested-action"],
|
||||||
|
)
|
||||||
|
test_btn.connect("clicked", lambda _: _on_test_launch(win))
|
||||||
|
test_row.add_suffix(test_btn)
|
||||||
|
debug_group.add(test_row)
|
||||||
|
|
||||||
|
# Shared mutable state for cross-function access
|
||||||
|
state = _PageState(
|
||||||
|
ce_status_row=ce_status_row,
|
||||||
|
ph_status_row=ph_status_row,
|
||||||
|
ce_path_row=ce_path_row,
|
||||||
|
dl_row=dl_row,
|
||||||
|
check_btn=check_btn,
|
||||||
|
progress_row=progress_row,
|
||||||
|
progress=progress,
|
||||||
|
ph_btn=ph_btn,
|
||||||
|
loc_row=loc_row,
|
||||||
|
loc_btn=loc_btn,
|
||||||
|
test_btn=test_btn,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initial status refresh
|
||||||
|
_refresh_status(win, state)
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
class _PageState:
|
||||||
|
"""Holds widget references for the CE page."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.release_info: dict | None = None
|
||||||
|
self.download_btn: Gtk.Button | None = None
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
setattr(self, k, v)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Status ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _refresh_status(win: PreferencesWindow, state: _PageState) -> None:
|
||||||
|
cfg = win._config
|
||||||
|
|
||||||
|
# CE status
|
||||||
|
if cfg.ce_exists:
|
||||||
|
version = cheatengine.detect_version(cfg.ce_executable_path)
|
||||||
|
ver_str = f" ({version})" if version else ""
|
||||||
|
state.ce_status_row.set_subtitle(_("✓ Installed") + ver_str)
|
||||||
|
state.test_btn.set_sensitive(True)
|
||||||
|
else:
|
||||||
|
state.ce_status_row.set_subtitle(_("✗ Not found"))
|
||||||
|
state.test_btn.set_sensitive(False)
|
||||||
|
|
||||||
|
# protonhax status
|
||||||
|
ph_path = protonhax.find_installed()
|
||||||
|
if ph_path:
|
||||||
|
if protonhax.is_managed():
|
||||||
|
if protonhax.needs_update():
|
||||||
|
state.ph_status_row.set_subtitle(_("⟳ Update available"))
|
||||||
|
state.ph_btn.set_label(_("Refresh"))
|
||||||
|
else:
|
||||||
|
state.ph_status_row.set_subtitle(_("✓ Installed"))
|
||||||
|
state.ph_btn.set_label(_("Reinstall"))
|
||||||
|
else:
|
||||||
|
state.ph_status_row.set_subtitle(_("✓ External: %s") % ph_path)
|
||||||
|
else:
|
||||||
|
state.ph_status_row.set_subtitle(_("✗ Not installed"))
|
||||||
|
state.ph_btn.set_label(_("Install"))
|
||||||
|
|
||||||
|
# Localization status
|
||||||
|
if cfg.ce_exists:
|
||||||
|
if cheatengine.is_localization_installed(cfg.ce_executable_path):
|
||||||
|
state.loc_row.set_subtitle(_("✓ Installed"))
|
||||||
|
state.loc_btn.set_label(_("Reinstall"))
|
||||||
|
else:
|
||||||
|
state.loc_row.set_subtitle(_("Not installed"))
|
||||||
|
state.loc_btn.set_label(_("Install"))
|
||||||
|
state.loc_btn.set_sensitive(True)
|
||||||
|
else:
|
||||||
|
state.loc_row.set_subtitle(_("Cheat Engine required"))
|
||||||
|
state.loc_btn.set_sensitive(False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_ce_path_changed(win: PreferencesWindow, row: Adw.EntryRow, state: _PageState) -> None:
|
||||||
|
win._config.ce_executable = row.get_text().strip()
|
||||||
|
win._save()
|
||||||
|
_refresh_status(win, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_browse_ce(win: PreferencesWindow, ce_path_row: Adw.EntryRow, state: _PageState) -> None:
|
||||||
|
dialog = Gtk.FileDialog(title=_("Select CE executable"))
|
||||||
|
|
||||||
|
filters = Gio.ListStore.new(Gtk.FileFilter)
|
||||||
|
exe_filter = Gtk.FileFilter()
|
||||||
|
exe_filter.set_name(_("Executable files (*.exe)"))
|
||||||
|
exe_filter.add_pattern("*.exe")
|
||||||
|
filters.append(exe_filter)
|
||||||
|
|
||||||
|
all_filter = Gtk.FileFilter()
|
||||||
|
all_filter.set_name(_("All files"))
|
||||||
|
all_filter.add_pattern("*")
|
||||||
|
filters.append(all_filter)
|
||||||
|
|
||||||
|
dialog.set_filters(filters)
|
||||||
|
|
||||||
|
def on_result(dlg: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
||||||
|
try:
|
||||||
|
gfile = dlg.open_finish(result)
|
||||||
|
if gfile:
|
||||||
|
path = gfile.get_path()
|
||||||
|
home = str(Path.home())
|
||||||
|
display = path.replace(home, "~", 1) if path.startswith(home) else path
|
||||||
|
ce_path_row.set_text(display)
|
||||||
|
except GLib.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dialog.open(win, None, on_result)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_lang_changed(win: PreferencesWindow, row: Adw.ComboRow) -> None:
|
||||||
|
langs = ["system", "ru_RU"]
|
||||||
|
win._config.ce_language = langs[row.get_selected()]
|
||||||
|
win._save()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_install_protonhax(win: PreferencesWindow, state: _PageState) -> None:
|
||||||
|
try:
|
||||||
|
protonhax.ensure_installed()
|
||||||
|
_refresh_status(win, state)
|
||||||
|
win._show_toast(_("protonhax installed"))
|
||||||
|
except OSError as e:
|
||||||
|
win._show_toast(_("Error: %s") % e)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_install_localization(
|
||||||
|
win: PreferencesWindow, loc_row: Adw.ActionRow, loc_btn: Gtk.Button,
|
||||||
|
) -> None:
|
||||||
|
cfg = win._config
|
||||||
|
if not cfg.ce_exists:
|
||||||
|
win._show_toast(_("Cheat Engine not found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
loc_btn.set_sensitive(False)
|
||||||
|
loc_row.set_subtitle(_("Downloading..."))
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
ok = cheatengine.install_localization(cfg.ce_executable_path)
|
||||||
|
GLib.idle_add(_on_localization_done, win, ok, loc_btn, loc_row)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_localization_done(
|
||||||
|
win: PreferencesWindow, success: bool, btn: Gtk.Button, loc_row: Adw.ActionRow,
|
||||||
|
) -> None:
|
||||||
|
btn.set_sensitive(True)
|
||||||
|
if success:
|
||||||
|
loc_row.set_subtitle(_("✓ Installed"))
|
||||||
|
btn.set_label(_("Reinstall"))
|
||||||
|
win._show_toast(_("Localization installed"))
|
||||||
|
else:
|
||||||
|
loc_row.set_subtitle(_("Not installed"))
|
||||||
|
win._show_toast(_("Failed to install localization"))
|
||||||
|
|
||||||
|
|
||||||
|
def _on_test_launch(win: PreferencesWindow) -> None:
|
||||||
|
cfg = win._config
|
||||||
|
if not cfg.ce_exists:
|
||||||
|
win._show_toast(_("Cheat Engine not found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
wine = cheatengine.find_proton_wine(cfg.steam_path)
|
||||||
|
if not wine:
|
||||||
|
win._show_toast(_("Wine not found — install Proton"))
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
prefix = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
|
||||||
|
prefix.mkdir(parents=True, exist_ok=True)
|
||||||
|
env["WINEPREFIX"] = str(prefix)
|
||||||
|
|
||||||
|
cmd = [str(wine), str(cfg.ce_executable_path)]
|
||||||
|
if cfg.ce_language != "system":
|
||||||
|
cmd.extend(["--LANG", cfg.ce_language])
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
start_new_session=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
win._show_toast(_("Cheat Engine launched"))
|
||||||
|
except OSError as e:
|
||||||
|
win._show_toast(_("Error: %s") % e)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Download & Extract ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_check_release(
|
||||||
|
win: PreferencesWindow, dl_row: Adw.ActionRow,
|
||||||
|
check_btn: Gtk.Button, state: _PageState,
|
||||||
|
) -> None:
|
||||||
|
dl_row.set_subtitle(_("Checking..."))
|
||||||
|
check_btn.set_sensitive(False)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
info = cheatengine.get_latest_release()
|
||||||
|
GLib.idle_add(_on_release_checked, win, info, dl_row, check_btn, state)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_release_checked(
|
||||||
|
win: PreferencesWindow, info: dict | None,
|
||||||
|
dl_row: Adw.ActionRow, check_btn: Gtk.Button, state: _PageState,
|
||||||
|
) -> None:
|
||||||
|
check_btn.set_sensitive(True)
|
||||||
|
|
||||||
|
if not info or not info.get("url"):
|
||||||
|
dl_row.set_subtitle(_("Failed to get info"))
|
||||||
|
return
|
||||||
|
|
||||||
|
state.release_info = info
|
||||||
|
version = info.get("version", "?")
|
||||||
|
size_mb = info.get("size", 0) / 1_048_576
|
||||||
|
|
||||||
|
subtitle = f"v{version}"
|
||||||
|
if size_mb > 0:
|
||||||
|
subtitle += f" ({size_mb:.1f} " + _("MB") + ")"
|
||||||
|
|
||||||
|
# Check if installed version matches latest
|
||||||
|
cfg = win._config
|
||||||
|
installed = cheatengine.detect_version(cfg.ce_executable_path) if cfg.ce_exists else None
|
||||||
|
already_installed = installed is not None and installed == version
|
||||||
|
|
||||||
|
if already_installed:
|
||||||
|
subtitle += " — " + _("already installed")
|
||||||
|
|
||||||
|
dl_row.set_subtitle(subtitle)
|
||||||
|
|
||||||
|
# Replace check button with download button
|
||||||
|
dl_row.remove(check_btn)
|
||||||
|
dl_btn = Gtk.Button(
|
||||||
|
label=_("Download"),
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["suggested-action"],
|
||||||
|
sensitive=not already_installed,
|
||||||
|
)
|
||||||
|
dl_btn.connect("clicked", lambda _: _on_download_ce(win, state))
|
||||||
|
dl_row.add_suffix(dl_btn)
|
||||||
|
state.download_btn = dl_btn
|
||||||
|
|
||||||
|
|
||||||
|
def _on_download_ce(win: PreferencesWindow, state: _PageState) -> None:
|
||||||
|
if not state.release_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
url = state.release_info["url"]
|
||||||
|
name = state.release_info.get("name", "CheatEngine.exe")
|
||||||
|
|
||||||
|
cache_dir = Path.home() / ".cache" / "vual"
|
||||||
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
installer = cache_dir / name
|
||||||
|
|
||||||
|
dest_dir = Path.home() / ".local" / "share" / "vual" / "cheatengine"
|
||||||
|
|
||||||
|
state.progress_row.set_visible(True)
|
||||||
|
state.progress_row.set_subtitle(_("Downloading: %s") % name)
|
||||||
|
state.progress.set_fraction(0)
|
||||||
|
if state.download_btn:
|
||||||
|
state.download_btn.set_sensitive(False)
|
||||||
|
|
||||||
|
def update_progress(frac: float) -> None:
|
||||||
|
GLib.idle_add(state.progress.set_fraction, frac)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
ok = cheatengine.download_file(url, installer, progress_cb=update_progress)
|
||||||
|
if not ok:
|
||||||
|
GLib.idle_add(_download_failed, state)
|
||||||
|
return
|
||||||
|
GLib.idle_add(_start_extraction, win, installer, dest_dir, state)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _download_failed(state: _PageState) -> None:
|
||||||
|
state.progress_row.set_visible(False)
|
||||||
|
if state.download_btn:
|
||||||
|
state.download_btn.set_sensitive(True)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_extraction(
|
||||||
|
win: PreferencesWindow, installer: Path, dest_dir: Path, state: _PageState,
|
||||||
|
) -> None:
|
||||||
|
state.progress_row.set_subtitle(_("Extracting..."))
|
||||||
|
state.progress.set_fraction(0.5)
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
extracted = cheatengine.extract_installer(
|
||||||
|
installer, dest_dir, win._config.steam_path,
|
||||||
|
)
|
||||||
|
exe = cheatengine.find_executable(dest_dir) if extracted else None
|
||||||
|
GLib.idle_add(_extraction_done, win, exe, extracted, installer, state)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _extraction_done(
|
||||||
|
win: PreferencesWindow,
|
||||||
|
exe_path: Path | None,
|
||||||
|
extracted: bool,
|
||||||
|
installer: Path,
|
||||||
|
state: _PageState,
|
||||||
|
) -> None:
|
||||||
|
state.progress_row.set_visible(False)
|
||||||
|
if state.download_btn:
|
||||||
|
state.download_btn.set_sensitive(True)
|
||||||
|
|
||||||
|
if exe_path:
|
||||||
|
home = str(Path.home())
|
||||||
|
display = str(exe_path).replace(home, "~", 1)
|
||||||
|
win._config.ce_executable = display
|
||||||
|
state.ce_path_row.set_text(display)
|
||||||
|
win._save()
|
||||||
|
_refresh_status(win, state)
|
||||||
|
installer.unlink(missing_ok=True)
|
||||||
|
win._show_toast(_("Cheat Engine installed"))
|
||||||
|
elif extracted:
|
||||||
|
_refresh_status(win, state)
|
||||||
|
win._show_toast(_("Extracted — specify path manually"))
|
||||||
|
else:
|
||||||
|
win._show_toast(_("Extraction failed"))
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Steam preferences page — paths, launch options, exclusions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
gi.require_version("Adw", "1")
|
||||||
|
|
||||||
|
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from vual.i18n import _ # noqa: E402
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from vual.ui.preferences import PreferencesWindow
|
||||||
|
|
||||||
|
|
||||||
|
def build_steam_page(win: PreferencesWindow) -> Adw.PreferencesPage:
|
||||||
|
"""Build the Steam preferences page."""
|
||||||
|
cfg = win._config
|
||||||
|
page = Adw.PreferencesPage(
|
||||||
|
title="Steam",
|
||||||
|
icon_name="folder-games-symbolic",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Paths group ──────────────────────────────────────────────
|
||||||
|
paths_group = Adw.PreferencesGroup(title=_("Paths"))
|
||||||
|
page.add(paths_group)
|
||||||
|
|
||||||
|
steam_row = Adw.EntryRow(title=_("Steam directory"))
|
||||||
|
steam_row.set_text(cfg.steam_path)
|
||||||
|
steam_row.connect("changed", lambda row: _on_steam_path_changed(win, row))
|
||||||
|
|
||||||
|
steam_browse = Gtk.Button(
|
||||||
|
icon_name="folder-open-symbolic",
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["flat"],
|
||||||
|
tooltip_text=_("Choose folder"),
|
||||||
|
)
|
||||||
|
steam_browse.connect("clicked", lambda _: _on_browse_steam(win, steam_row))
|
||||||
|
steam_row.add_suffix(steam_browse)
|
||||||
|
paths_group.add(steam_row)
|
||||||
|
|
||||||
|
# ── Launch options group ─────────────────────────────────────
|
||||||
|
launch_group = Adw.PreferencesGroup(
|
||||||
|
title=_("Launch options"),
|
||||||
|
description=_("LaunchOptions template for protonhax"),
|
||||||
|
)
|
||||||
|
page.add(launch_group)
|
||||||
|
|
||||||
|
template_row = Adw.EntryRow(title=_("Template"))
|
||||||
|
template_row.set_text(cfg.launch_options_template)
|
||||||
|
template_row.connect("changed", lambda row: _on_template_changed(win, row))
|
||||||
|
launch_group.add(template_row)
|
||||||
|
|
||||||
|
hint_row = Adw.ActionRow(
|
||||||
|
title="%COMMAND%",
|
||||||
|
subtitle=_("Original launch command is substituted"),
|
||||||
|
)
|
||||||
|
hint_row.set_activatable(False)
|
||||||
|
hint_row.add_css_class("dim-label")
|
||||||
|
launch_group.add(hint_row)
|
||||||
|
|
||||||
|
# ── Exclusions group ─────────────────────────────────────────
|
||||||
|
excl_group = Adw.PreferencesGroup(
|
||||||
|
title=_("Exclusions"),
|
||||||
|
description=_("Regex patterns to hide apps"),
|
||||||
|
)
|
||||||
|
page.add(excl_group)
|
||||||
|
|
||||||
|
exclusion_rows: list[Adw.EntryRow] = []
|
||||||
|
win._exclusion_rows = exclusion_rows
|
||||||
|
|
||||||
|
for pattern in cfg.excluded_app_patterns:
|
||||||
|
_add_exclusion_row(win, excl_group, exclusion_rows, pattern)
|
||||||
|
|
||||||
|
add_row = Adw.ActionRow(title=_("Add pattern"))
|
||||||
|
add_btn = Gtk.Button(
|
||||||
|
icon_name="list-add-symbolic",
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["flat"],
|
||||||
|
)
|
||||||
|
add_btn.connect("clicked", lambda _: _on_add_exclusion(win, excl_group, exclusion_rows))
|
||||||
|
add_row.add_suffix(add_btn)
|
||||||
|
add_row.set_activatable_widget(add_btn)
|
||||||
|
excl_group.add(add_row)
|
||||||
|
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
# ── Callbacks ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _on_steam_path_changed(win: PreferencesWindow, row: Adw.EntryRow) -> None:
|
||||||
|
win._config.steam_path = row.get_text().strip()
|
||||||
|
win._save()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_template_changed(win: PreferencesWindow, row: Adw.EntryRow) -> None:
|
||||||
|
win._config.launch_options_template = row.get_text().strip()
|
||||||
|
win._save()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_browse_steam(win: PreferencesWindow, steam_row: Adw.EntryRow) -> None:
|
||||||
|
dialog = Gtk.FileDialog(title=_("Select Steam directory"))
|
||||||
|
|
||||||
|
def on_result(dlg: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
||||||
|
try:
|
||||||
|
folder = dlg.select_folder_finish(result)
|
||||||
|
if folder:
|
||||||
|
path = folder.get_path()
|
||||||
|
home = str(Path.home())
|
||||||
|
display = path.replace(home, "~", 1) if path.startswith(home) else path
|
||||||
|
steam_row.set_text(display)
|
||||||
|
except GLib.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dialog.select_folder(win, None, on_result)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_exclusion_row(
|
||||||
|
win: PreferencesWindow,
|
||||||
|
group: Adw.PreferencesGroup,
|
||||||
|
rows: list[Adw.EntryRow],
|
||||||
|
text: str = "",
|
||||||
|
) -> Adw.EntryRow:
|
||||||
|
row = Adw.EntryRow(title="Regex")
|
||||||
|
row.set_text(text)
|
||||||
|
row.connect("changed", lambda _: _sync_exclusions(win, rows))
|
||||||
|
|
||||||
|
remove_btn = Gtk.Button(
|
||||||
|
icon_name="user-trash-symbolic",
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
css_classes=["flat", "error"],
|
||||||
|
tooltip_text=_("Remove"),
|
||||||
|
)
|
||||||
|
remove_btn.connect("clicked", lambda _: _on_remove_exclusion(win, group, rows, row))
|
||||||
|
row.add_suffix(remove_btn)
|
||||||
|
|
||||||
|
rows.append(row)
|
||||||
|
group.add(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _on_add_exclusion(
|
||||||
|
win: PreferencesWindow,
|
||||||
|
group: Adw.PreferencesGroup,
|
||||||
|
rows: list[Adw.EntryRow],
|
||||||
|
) -> None:
|
||||||
|
row = _add_exclusion_row(win, group, rows)
|
||||||
|
row.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_remove_exclusion(
|
||||||
|
win: PreferencesWindow,
|
||||||
|
group: Adw.PreferencesGroup,
|
||||||
|
rows: list[Adw.EntryRow],
|
||||||
|
row: Adw.EntryRow,
|
||||||
|
) -> None:
|
||||||
|
if row in rows:
|
||||||
|
rows.remove(row)
|
||||||
|
group.remove(row)
|
||||||
|
_sync_exclusions(win, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_exclusions(win: PreferencesWindow, rows: list[Adw.EntryRow]) -> None:
|
||||||
|
win._config.excluded_app_patterns = [
|
||||||
|
row.get_text().strip()
|
||||||
|
for row in rows
|
||||||
|
if row.get_text().strip()
|
||||||
|
]
|
||||||
|
win._save()
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
"""Preferences window — clean libadwaita implementation.
|
"""Preferences window — thin shell assembling page modules.
|
||||||
|
|
||||||
Uses Adw.PreferencesWindow with auto-save on changes.
|
Uses Adw.PreferencesWindow with auto-save on changes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Adw", "1")
|
gi.require_version("Adw", "1")
|
||||||
|
|
||||||
from gi.repository import Adw, Gio, GLib, Gtk
|
from gi.repository import Adw, GLib # noqa: E402
|
||||||
|
|
||||||
from vual import cheatengine, protonhax, wine_theme
|
from vual.config import Config # noqa: E402
|
||||||
from vual.config import Config
|
from vual.i18n import _ # noqa: E402
|
||||||
from vual.i18n import _
|
from vual.ui.pref_appearance import build_appearance_page # noqa: E402
|
||||||
|
from vual.ui.pref_ce import build_ce_page # noqa: E402
|
||||||
|
from vual.ui.pref_steam import build_steam_page # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
class PreferencesWindow(Adw.PreferencesWindow):
|
class PreferencesWindow(Adw.PreferencesWindow):
|
||||||
@@ -30,12 +29,9 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
)
|
)
|
||||||
self._config = config
|
self._config = config
|
||||||
self._parent = kwargs.get("transient_for")
|
self._parent = kwargs.get("transient_for")
|
||||||
self._exclusion_rows: list[Adw.EntryRow] = []
|
self._building = True
|
||||||
self._release_info: dict | None = None
|
|
||||||
self._building = True # Prevent saves during initial build
|
|
||||||
|
|
||||||
self._build_pages()
|
self._build_pages()
|
||||||
self._refresh_status()
|
|
||||||
self._building = False
|
self._building = False
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
@@ -44,756 +40,18 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
return
|
return
|
||||||
self._config.save()
|
self._config.save()
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════
|
def _show_toast(self, message: str) -> None:
|
||||||
# Pages
|
"""Show a toast notification."""
|
||||||
# ════════════════════════════════════════════════════════════════
|
toast = Adw.Toast(title=message, timeout=3)
|
||||||
|
self.add_toast(toast)
|
||||||
|
|
||||||
def _build_pages(self) -> None:
|
def _build_pages(self) -> None:
|
||||||
"""Build all preference pages."""
|
"""Build all preference pages from modules."""
|
||||||
for build in (self._build_appearance_page, self._build_steam_page, self._build_ce_page):
|
for build in (build_appearance_page, build_steam_page, build_ce_page):
|
||||||
page = build()
|
page = build(self)
|
||||||
page.connect("map", self._on_page_mapped)
|
page.connect("map", self._on_page_mapped)
|
||||||
self.add(page)
|
self.add(page)
|
||||||
|
|
||||||
def _on_page_mapped(self, page: Adw.PreferencesPage) -> None:
|
def _on_page_mapped(self, page: Adw.PreferencesPage) -> None:
|
||||||
"""Reset focus when switching pages to avoid auto-selecting EntryRow."""
|
"""Reset focus when switching pages to avoid auto-selecting EntryRow."""
|
||||||
GLib.idle_add(self.set_focus, None)
|
GLib.idle_add(self.set_focus, None)
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
# Appearance Page
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_appearance_page(self) -> Adw.PreferencesPage:
|
|
||||||
page = Adw.PreferencesPage(
|
|
||||||
title=_("Appearance"),
|
|
||||||
icon_name="preferences-desktop-appearance-symbolic",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Theme group
|
|
||||||
theme_group = Adw.PreferencesGroup(title=_("Theme"))
|
|
||||||
page.add(theme_group)
|
|
||||||
|
|
||||||
self._theme_row = Adw.ComboRow(
|
|
||||||
title=_("Color scheme"),
|
|
||||||
subtitle=_("Light, dark, or system"),
|
|
||||||
)
|
|
||||||
model = Gtk.StringList.new([_("System"), _("Light"), _("Dark")])
|
|
||||||
self._theme_row.set_model(model)
|
|
||||||
idx = {"system": 0, "light": 1, "dark": 2}.get(self._config.color_scheme, 0)
|
|
||||||
self._theme_row.set_selected(idx)
|
|
||||||
self._theme_row.connect("notify::selected", self._on_theme_changed)
|
|
||||||
theme_group.add(self._theme_row)
|
|
||||||
|
|
||||||
# Language selection
|
|
||||||
self._lang_app_row = Adw.ComboRow(
|
|
||||||
title=_("Language"),
|
|
||||||
subtitle=_("Application interface language"),
|
|
||||||
)
|
|
||||||
lang_app_model = Gtk.StringList.new([_("System"), "English", "Русский"])
|
|
||||||
self._lang_app_row.set_model(lang_app_model)
|
|
||||||
lang_app_idx = {"system": 0, "en": 1, "ru": 2}.get(self._config.app_language, 0)
|
|
||||||
self._lang_app_row.set_selected(lang_app_idx)
|
|
||||||
self._lang_app_row.connect("notify::selected", self._on_app_lang_changed)
|
|
||||||
theme_group.add(self._lang_app_row)
|
|
||||||
|
|
||||||
# Transparent window toggle
|
|
||||||
self._transparent_row = Adw.SwitchRow(
|
|
||||||
title=_("Transparent window"),
|
|
||||||
subtitle=_("Semi-transparent background"),
|
|
||||||
)
|
|
||||||
self._transparent_row.set_active(self._config.transparent_window)
|
|
||||||
self._transparent_row.connect("notify::active", self._on_transparent_changed)
|
|
||||||
theme_group.add(self._transparent_row)
|
|
||||||
|
|
||||||
# Grid group
|
|
||||||
grid_group = Adw.PreferencesGroup(
|
|
||||||
title=_("Grid"),
|
|
||||||
description=_("Game display settings"),
|
|
||||||
)
|
|
||||||
page.add(grid_group)
|
|
||||||
|
|
||||||
self._tile_row = Adw.ComboRow(
|
|
||||||
title=_("Tile size"),
|
|
||||||
subtitle=_("Cover size in library"),
|
|
||||||
)
|
|
||||||
tile_model = Gtk.StringList.new([_("Small (120px)"), _("Medium (150px)"), _("Large (180px)")])
|
|
||||||
self._tile_row.set_model(tile_model)
|
|
||||||
tile_idx = {"small": 0, "medium": 1, "large": 2}.get(self._config.tile_size, 1)
|
|
||||||
self._tile_row.set_selected(tile_idx)
|
|
||||||
self._tile_row.connect("notify::selected", self._on_tile_size_changed)
|
|
||||||
grid_group.add(self._tile_row)
|
|
||||||
|
|
||||||
self._sort_row = Adw.ComboRow(
|
|
||||||
title=_("Sort"),
|
|
||||||
subtitle=_("Default game order"),
|
|
||||||
)
|
|
||||||
sort_model = Gtk.StringList.new([_("By name"), _("By status")])
|
|
||||||
self._sort_row.set_model(sort_model)
|
|
||||||
sort_idx = {"name": 0, "status": 1}.get(self._config.sort_by, 0)
|
|
||||||
self._sort_row.set_selected(sort_idx)
|
|
||||||
self._sort_row.connect("notify::selected", self._on_sort_changed)
|
|
||||||
grid_group.add(self._sort_row)
|
|
||||||
|
|
||||||
# Wine theme group
|
|
||||||
wine_group = Adw.PreferencesGroup(
|
|
||||||
title=_("Wine Theme"),
|
|
||||||
description=_("Color scheme for Cheat Engine and other Wine apps"),
|
|
||||||
)
|
|
||||||
page.add(wine_group)
|
|
||||||
|
|
||||||
self._wine_theme_row = Adw.ComboRow(
|
|
||||||
title=_("Color scheme"),
|
|
||||||
subtitle=_("Applied to all Proton prefixes"),
|
|
||||||
)
|
|
||||||
wine_model = Gtk.StringList.new([_("System"), _("Dark"), _("Light")])
|
|
||||||
self._wine_theme_row.set_model(wine_model)
|
|
||||||
wine_idx = {"system": 0, "dark": 1, "light": 2}.get(self._config.wine_theme, 0)
|
|
||||||
self._wine_theme_row.set_selected(wine_idx)
|
|
||||||
self._wine_theme_row.connect("notify::selected", self._on_wine_theme_changed)
|
|
||||||
|
|
||||||
# Refresh button
|
|
||||||
self._wine_refresh_btn = Gtk.Button(
|
|
||||||
icon_name="view-refresh-symbolic",
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["flat"],
|
|
||||||
tooltip_text=_("Reapply theme"),
|
|
||||||
)
|
|
||||||
self._wine_refresh_btn.connect("clicked", self._on_wine_theme_refresh)
|
|
||||||
self._wine_theme_row.add_suffix(self._wine_refresh_btn)
|
|
||||||
|
|
||||||
wine_group.add(self._wine_theme_row)
|
|
||||||
|
|
||||||
return page
|
|
||||||
|
|
||||||
def _on_theme_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
|
||||||
schemes = ["system", "light", "dark"]
|
|
||||||
self._config.color_scheme = schemes[row.get_selected()]
|
|
||||||
self._save()
|
|
||||||
self._apply_theme()
|
|
||||||
|
|
||||||
def _on_tile_size_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
|
||||||
sizes = ["small", "medium", "large"]
|
|
||||||
self._config.tile_size = sizes[row.get_selected()]
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def _on_sort_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
|
||||||
sorts = ["name", "status"]
|
|
||||||
self._config.sort_by = sorts[row.get_selected()]
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def _on_app_lang_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
|
||||||
langs = ["system", "en", "ru"]
|
|
||||||
lang = langs[row.get_selected()]
|
|
||||||
self._config.app_language = lang
|
|
||||||
self._save()
|
|
||||||
# Show restart hint
|
|
||||||
self._show_toast(_("Restart app to apply language"))
|
|
||||||
|
|
||||||
def _on_transparent_changed(self, row: Adw.SwitchRow, _pspec) -> None:
|
|
||||||
self._config.transparent_window = row.get_active()
|
|
||||||
self._save()
|
|
||||||
if self._parent:
|
|
||||||
self._parent.apply_transparency()
|
|
||||||
|
|
||||||
def _apply_theme(self) -> None:
|
|
||||||
style = Adw.StyleManager.get_default()
|
|
||||||
schemes = {
|
|
||||||
"light": Adw.ColorScheme.FORCE_LIGHT,
|
|
||||||
"dark": Adw.ColorScheme.FORCE_DARK,
|
|
||||||
"system": Adw.ColorScheme.DEFAULT,
|
|
||||||
}
|
|
||||||
style.set_color_scheme(schemes.get(self._config.color_scheme, Adw.ColorScheme.DEFAULT))
|
|
||||||
|
|
||||||
def _get_system_theme(self) -> str:
|
|
||||||
"""Detect system color scheme. Returns 'dark' or 'light'."""
|
|
||||||
style = Adw.StyleManager.get_default()
|
|
||||||
return "dark" if style.get_dark() else "light"
|
|
||||||
|
|
||||||
def _on_wine_theme_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
|
||||||
"""Handle Wine theme change — save and apply immediately."""
|
|
||||||
themes = ["system", "dark", "light"]
|
|
||||||
theme = themes[row.get_selected()]
|
|
||||||
self._config.wine_theme = theme
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
# For "system", detect actual system theme
|
|
||||||
actual_theme = self._get_system_theme() if theme == "system" else theme
|
|
||||||
|
|
||||||
# Apply in background
|
|
||||||
row.set_sensitive(False)
|
|
||||||
self._wine_refresh_btn.set_sensitive(False)
|
|
||||||
|
|
||||||
def worker() -> None:
|
|
||||||
steamapps = self._config.steamapps_path
|
|
||||||
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
|
|
||||||
GLib.idle_add(self._on_wine_theme_applied, row, success, failed)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _on_wine_theme_applied(self, row: Adw.ComboRow, success: int, failed: int) -> None:
|
|
||||||
"""Handle Wine theme application result."""
|
|
||||||
row.set_sensitive(True)
|
|
||||||
self._wine_refresh_btn.set_sensitive(True)
|
|
||||||
|
|
||||||
if success > 0:
|
|
||||||
self._show_toast(_("Theme applied to %d prefixes") % success)
|
|
||||||
elif failed > 0:
|
|
||||||
self._show_toast(_("Error applying to %d prefixes") % failed)
|
|
||||||
|
|
||||||
def _on_wine_theme_refresh(self, _btn: Gtk.Button) -> None:
|
|
||||||
"""Refresh/reapply Wine theme to all prefixes."""
|
|
||||||
theme = self._config.wine_theme
|
|
||||||
actual_theme = self._get_system_theme() if theme == "system" else theme
|
|
||||||
|
|
||||||
self._wine_theme_row.set_sensitive(False)
|
|
||||||
self._wine_refresh_btn.set_sensitive(False)
|
|
||||||
|
|
||||||
def worker() -> None:
|
|
||||||
steamapps = self._config.steamapps_path
|
|
||||||
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
|
|
||||||
GLib.idle_add(self._on_wine_theme_applied, self._wine_theme_row, success, failed)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _show_toast(self, message: str) -> None:
|
|
||||||
"""Show a toast notification."""
|
|
||||||
toast = Adw.Toast(title=message, timeout=3)
|
|
||||||
self.add_toast(toast)
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
# Steam Page
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_steam_page(self) -> Adw.PreferencesPage:
|
|
||||||
page = Adw.PreferencesPage(
|
|
||||||
title="Steam",
|
|
||||||
icon_name="folder-games-symbolic",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Paths group
|
|
||||||
paths_group = Adw.PreferencesGroup(title=_("Paths"))
|
|
||||||
page.add(paths_group)
|
|
||||||
|
|
||||||
self._steam_row = Adw.EntryRow(title=_("Steam directory"))
|
|
||||||
self._steam_row.set_text(self._config.steam_path)
|
|
||||||
self._steam_row.connect("changed", self._on_steam_path_changed)
|
|
||||||
|
|
||||||
steam_browse = Gtk.Button(
|
|
||||||
icon_name="folder-open-symbolic",
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["flat"],
|
|
||||||
tooltip_text=_("Choose folder"),
|
|
||||||
)
|
|
||||||
steam_browse.connect("clicked", self._on_browse_steam)
|
|
||||||
self._steam_row.add_suffix(steam_browse)
|
|
||||||
paths_group.add(self._steam_row)
|
|
||||||
|
|
||||||
# Launch options group
|
|
||||||
launch_group = Adw.PreferencesGroup(
|
|
||||||
title=_("Launch options"),
|
|
||||||
description=_("LaunchOptions template for protonhax"),
|
|
||||||
)
|
|
||||||
page.add(launch_group)
|
|
||||||
|
|
||||||
self._template_row = Adw.EntryRow(title=_("Template"))
|
|
||||||
self._template_row.set_text(self._config.launch_options_template)
|
|
||||||
self._template_row.connect("changed", self._on_template_changed)
|
|
||||||
launch_group.add(self._template_row)
|
|
||||||
|
|
||||||
hint_row = Adw.ActionRow(
|
|
||||||
title="%COMMAND%",
|
|
||||||
subtitle=_("Original launch command is substituted"),
|
|
||||||
)
|
|
||||||
hint_row.set_activatable(False)
|
|
||||||
hint_row.add_css_class("dim-label")
|
|
||||||
launch_group.add(hint_row)
|
|
||||||
|
|
||||||
# Exclusions group
|
|
||||||
excl_group = Adw.PreferencesGroup(
|
|
||||||
title=_("Exclusions"),
|
|
||||||
description=_("Regex patterns to hide apps"),
|
|
||||||
)
|
|
||||||
page.add(excl_group)
|
|
||||||
self._excl_group = excl_group
|
|
||||||
|
|
||||||
for pattern in self._config.excluded_app_patterns:
|
|
||||||
self._add_exclusion_row(pattern)
|
|
||||||
|
|
||||||
add_row = Adw.ActionRow(title=_("Add pattern"))
|
|
||||||
add_btn = Gtk.Button(
|
|
||||||
icon_name="list-add-symbolic",
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["flat"],
|
|
||||||
)
|
|
||||||
add_btn.connect("clicked", self._on_add_exclusion)
|
|
||||||
add_row.add_suffix(add_btn)
|
|
||||||
add_row.set_activatable_widget(add_btn)
|
|
||||||
excl_group.add(add_row)
|
|
||||||
self._add_excl_row = add_row
|
|
||||||
|
|
||||||
return page
|
|
||||||
|
|
||||||
def _on_steam_path_changed(self, row: Adw.EntryRow) -> None:
|
|
||||||
self._config.steam_path = row.get_text().strip()
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def _on_template_changed(self, row: Adw.EntryRow) -> None:
|
|
||||||
self._config.launch_options_template = row.get_text().strip()
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def _on_browse_steam(self, _btn: Gtk.Button) -> None:
|
|
||||||
dialog = Gtk.FileDialog(title=_("Select Steam directory"))
|
|
||||||
dialog.select_folder(self, None, self._on_steam_folder_selected)
|
|
||||||
|
|
||||||
def _on_steam_folder_selected(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
|
||||||
try:
|
|
||||||
folder = dialog.select_folder_finish(result)
|
|
||||||
if folder:
|
|
||||||
path = folder.get_path()
|
|
||||||
home = str(Path.home())
|
|
||||||
display = path.replace(home, "~", 1) if path.startswith(home) else path
|
|
||||||
self._steam_row.set_text(display)
|
|
||||||
except GLib.Error:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _add_exclusion_row(self, text: str = "") -> Adw.EntryRow:
|
|
||||||
row = Adw.EntryRow(title="Regex")
|
|
||||||
row.set_text(text)
|
|
||||||
row.connect("changed", self._on_exclusion_changed)
|
|
||||||
|
|
||||||
remove_btn = Gtk.Button(
|
|
||||||
icon_name="user-trash-symbolic",
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["flat", "error"],
|
|
||||||
tooltip_text=_("Remove"),
|
|
||||||
)
|
|
||||||
remove_btn.connect("clicked", self._on_remove_exclusion, row)
|
|
||||||
row.add_suffix(remove_btn)
|
|
||||||
|
|
||||||
self._exclusion_rows.append(row)
|
|
||||||
self._excl_group.add(row)
|
|
||||||
return row
|
|
||||||
|
|
||||||
def _on_add_exclusion(self, _btn: Gtk.Button) -> None:
|
|
||||||
row = self._add_exclusion_row()
|
|
||||||
row.grab_focus()
|
|
||||||
|
|
||||||
def _on_remove_exclusion(self, _btn: Gtk.Button, row: Adw.EntryRow) -> None:
|
|
||||||
if row in self._exclusion_rows:
|
|
||||||
self._exclusion_rows.remove(row)
|
|
||||||
self._excl_group.remove(row)
|
|
||||||
self._sync_exclusions()
|
|
||||||
|
|
||||||
def _on_exclusion_changed(self, _row: Adw.EntryRow) -> None:
|
|
||||||
self._sync_exclusions()
|
|
||||||
|
|
||||||
def _sync_exclusions(self) -> None:
|
|
||||||
self._config.excluded_app_patterns = [
|
|
||||||
row.get_text().strip()
|
|
||||||
for row in self._exclusion_rows
|
|
||||||
if row.get_text().strip()
|
|
||||||
]
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
# Cheat Engine Page
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _build_ce_page(self) -> Adw.PreferencesPage:
|
|
||||||
page = Adw.PreferencesPage(
|
|
||||||
title="Cheat Engine",
|
|
||||||
icon_name="applications-games-symbolic",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status group
|
|
||||||
status_group = Adw.PreferencesGroup(title=_("Status"))
|
|
||||||
page.add(status_group)
|
|
||||||
|
|
||||||
self._ce_status_row = Adw.ActionRow(
|
|
||||||
title="Cheat Engine",
|
|
||||||
subtitle=_("Checking..."),
|
|
||||||
)
|
|
||||||
self._ce_status_row.set_activatable(False)
|
|
||||||
status_group.add(self._ce_status_row)
|
|
||||||
|
|
||||||
self._ph_status_row = Adw.ActionRow(
|
|
||||||
title="protonhax",
|
|
||||||
subtitle=_("Checking..."),
|
|
||||||
)
|
|
||||||
self._ph_status_row.set_activatable(False)
|
|
||||||
status_group.add(self._ph_status_row)
|
|
||||||
|
|
||||||
# Installation group
|
|
||||||
install_group = Adw.PreferencesGroup(
|
|
||||||
title=_("Installation"),
|
|
||||||
description=_("Cheat Engine setup and download"),
|
|
||||||
)
|
|
||||||
page.add(install_group)
|
|
||||||
|
|
||||||
self._ce_path_row = Adw.EntryRow(title=_("Executable"))
|
|
||||||
self._ce_path_row.set_text(self._config.ce_executable)
|
|
||||||
self._ce_path_row.connect("changed", self._on_ce_path_changed)
|
|
||||||
|
|
||||||
browse_btn = Gtk.Button(
|
|
||||||
icon_name="document-open-symbolic",
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["flat"],
|
|
||||||
tooltip_text=_("Choose file"),
|
|
||||||
)
|
|
||||||
browse_btn.connect("clicked", self._on_browse_ce)
|
|
||||||
self._ce_path_row.add_suffix(browse_btn)
|
|
||||||
install_group.add(self._ce_path_row)
|
|
||||||
|
|
||||||
self._dl_row = Adw.ActionRow(
|
|
||||||
title=_("Latest version"),
|
|
||||||
subtitle=_("Click Check"),
|
|
||||||
)
|
|
||||||
self._check_btn = Gtk.Button(
|
|
||||||
label=_("Check"),
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
)
|
|
||||||
self._check_btn.connect("clicked", self._on_check_release)
|
|
||||||
self._dl_row.add_suffix(self._check_btn)
|
|
||||||
install_group.add(self._dl_row)
|
|
||||||
|
|
||||||
self._progress_row = Adw.ActionRow(
|
|
||||||
title=_("Download"),
|
|
||||||
subtitle="",
|
|
||||||
visible=False,
|
|
||||||
)
|
|
||||||
self._progress = Gtk.ProgressBar(
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
show_text=True,
|
|
||||||
)
|
|
||||||
self._progress.set_size_request(150, -1)
|
|
||||||
self._progress_row.add_suffix(self._progress)
|
|
||||||
install_group.add(self._progress_row)
|
|
||||||
|
|
||||||
# protonhax group
|
|
||||||
ph_group = Adw.PreferencesGroup(
|
|
||||||
title="protonhax",
|
|
||||||
description=_("Required for attaching to Proton games"),
|
|
||||||
)
|
|
||||||
page.add(ph_group)
|
|
||||||
|
|
||||||
ph_row = Adw.ActionRow(
|
|
||||||
title=_("Install or update"),
|
|
||||||
subtitle=_("Downloads latest version from GitHub"),
|
|
||||||
)
|
|
||||||
self._ph_btn = Gtk.Button(
|
|
||||||
label=_("Install"),
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
)
|
|
||||||
self._ph_btn.connect("clicked", self._on_install_protonhax)
|
|
||||||
ph_row.add_suffix(self._ph_btn)
|
|
||||||
ph_group.add(ph_row)
|
|
||||||
|
|
||||||
# Language group
|
|
||||||
lang_group = Adw.PreferencesGroup(
|
|
||||||
title=_("Language"),
|
|
||||||
description=_("Cheat Engine interface language"),
|
|
||||||
)
|
|
||||||
page.add(lang_group)
|
|
||||||
|
|
||||||
self._lang_row = Adw.ComboRow(
|
|
||||||
title=_("Interface"),
|
|
||||||
subtitle=_("Requires CE restart"),
|
|
||||||
)
|
|
||||||
lang_model = Gtk.StringList.new([_("System"), _("Russian")])
|
|
||||||
self._lang_row.set_model(lang_model)
|
|
||||||
lang_idx = {"system": 0, "ru_RU": 1}.get(self._config.ce_language, 0)
|
|
||||||
self._lang_row.set_selected(lang_idx)
|
|
||||||
self._lang_row.connect("notify::selected", self._on_lang_changed)
|
|
||||||
lang_group.add(self._lang_row)
|
|
||||||
|
|
||||||
self._loc_row = Adw.ActionRow(
|
|
||||||
title=_("Russian localization"),
|
|
||||||
subtitle=_("Checking..."),
|
|
||||||
)
|
|
||||||
self._loc_btn = Gtk.Button(
|
|
||||||
label=_("Install"),
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
)
|
|
||||||
self._loc_btn.connect("clicked", self._on_install_localization)
|
|
||||||
self._loc_row.add_suffix(self._loc_btn)
|
|
||||||
lang_group.add(self._loc_row)
|
|
||||||
|
|
||||||
# Debug group
|
|
||||||
debug_group = Adw.PreferencesGroup(title=_("Debug"))
|
|
||||||
page.add(debug_group)
|
|
||||||
|
|
||||||
test_row = Adw.ActionRow(
|
|
||||||
title=_("Test launch"),
|
|
||||||
subtitle=_("Launch CE via Wine (without game binding)"),
|
|
||||||
)
|
|
||||||
self._test_btn = Gtk.Button(
|
|
||||||
label=_("Launch"),
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["suggested-action"],
|
|
||||||
)
|
|
||||||
self._test_btn.connect("clicked", self._on_test_launch)
|
|
||||||
test_row.add_suffix(self._test_btn)
|
|
||||||
debug_group.add(test_row)
|
|
||||||
|
|
||||||
return page
|
|
||||||
|
|
||||||
def _on_ce_path_changed(self, row: Adw.EntryRow) -> None:
|
|
||||||
self._config.ce_executable = row.get_text().strip()
|
|
||||||
self._save()
|
|
||||||
self._refresh_status()
|
|
||||||
|
|
||||||
def _on_browse_ce(self, _btn: Gtk.Button) -> None:
|
|
||||||
dialog = Gtk.FileDialog(title=_("Select CE executable"))
|
|
||||||
|
|
||||||
filters = Gio.ListStore.new(Gtk.FileFilter)
|
|
||||||
exe_filter = Gtk.FileFilter()
|
|
||||||
exe_filter.set_name(_("Executable files (*.exe)"))
|
|
||||||
exe_filter.add_pattern("*.exe")
|
|
||||||
filters.append(exe_filter)
|
|
||||||
|
|
||||||
all_filter = Gtk.FileFilter()
|
|
||||||
all_filter.set_name(_("All files"))
|
|
||||||
all_filter.add_pattern("*")
|
|
||||||
filters.append(all_filter)
|
|
||||||
|
|
||||||
dialog.set_filters(filters)
|
|
||||||
dialog.open(self, None, self._on_ce_file_selected)
|
|
||||||
|
|
||||||
def _on_ce_file_selected(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
|
|
||||||
try:
|
|
||||||
gfile = dialog.open_finish(result)
|
|
||||||
if gfile:
|
|
||||||
path = gfile.get_path()
|
|
||||||
home = str(Path.home())
|
|
||||||
display = path.replace(home, "~", 1) if path.startswith(home) else path
|
|
||||||
self._ce_path_row.set_text(display)
|
|
||||||
except GLib.Error:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
# Status & Actions
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _refresh_status(self) -> None:
|
|
||||||
"""Update CE and protonhax status."""
|
|
||||||
# CE status
|
|
||||||
if self._config.ce_exists:
|
|
||||||
version = cheatengine.detect_version(self._config.ce_executable_path)
|
|
||||||
ver_str = f" ({version})" if version else ""
|
|
||||||
self._ce_status_row.set_subtitle(_("✓ Installed") + ver_str)
|
|
||||||
self._test_btn.set_sensitive(True)
|
|
||||||
else:
|
|
||||||
self._ce_status_row.set_subtitle(_("✗ Not found"))
|
|
||||||
self._test_btn.set_sensitive(False)
|
|
||||||
|
|
||||||
# protonhax status
|
|
||||||
ph_path = protonhax.find_installed()
|
|
||||||
if ph_path:
|
|
||||||
if protonhax.is_managed():
|
|
||||||
if protonhax.needs_update():
|
|
||||||
self._ph_status_row.set_subtitle(_("⟳ Update available"))
|
|
||||||
self._ph_btn.set_label(_("Refresh"))
|
|
||||||
else:
|
|
||||||
self._ph_status_row.set_subtitle(_("✓ Installed"))
|
|
||||||
self._ph_btn.set_label(_("Reinstall"))
|
|
||||||
else:
|
|
||||||
self._ph_status_row.set_subtitle(_("✓ External: %s") % ph_path)
|
|
||||||
else:
|
|
||||||
self._ph_status_row.set_subtitle(_("✗ Not installed"))
|
|
||||||
self._ph_btn.set_label(_("Install"))
|
|
||||||
|
|
||||||
# Localization status
|
|
||||||
if self._config.ce_exists:
|
|
||||||
if cheatengine.is_localization_installed(self._config.ce_executable_path):
|
|
||||||
self._loc_row.set_subtitle(_("✓ Installed"))
|
|
||||||
self._loc_btn.set_label(_("Reinstall"))
|
|
||||||
else:
|
|
||||||
self._loc_row.set_subtitle(_("Not installed"))
|
|
||||||
self._loc_btn.set_label(_("Install"))
|
|
||||||
self._loc_btn.set_sensitive(True)
|
|
||||||
else:
|
|
||||||
self._loc_row.set_subtitle(_("Cheat Engine required"))
|
|
||||||
self._loc_btn.set_sensitive(False)
|
|
||||||
|
|
||||||
def _on_test_launch(self, _btn: Gtk.Button) -> None:
|
|
||||||
if not self._config.ce_exists:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Cheat Engine not found")))
|
|
||||||
return
|
|
||||||
|
|
||||||
wine = cheatengine.find_proton_wine(self._config.steam_path)
|
|
||||||
if not wine:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Wine not found — install Proton")))
|
|
||||||
return
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
prefix = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
|
|
||||||
prefix.mkdir(parents=True, exist_ok=True)
|
|
||||||
env["WINEPREFIX"] = str(prefix)
|
|
||||||
|
|
||||||
cmd = [str(wine), str(self._config.ce_executable_path)]
|
|
||||||
if self._config.ce_language != "system":
|
|
||||||
cmd.extend(["--LANG", self._config.ce_language])
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
env=env,
|
|
||||||
start_new_session=True,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
self.add_toast(Adw.Toast(title=_("Cheat Engine launched")))
|
|
||||||
except OSError as e:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Error: %s") % e))
|
|
||||||
|
|
||||||
def _on_install_protonhax(self, _btn: Gtk.Button) -> None:
|
|
||||||
try:
|
|
||||||
protonhax.ensure_installed()
|
|
||||||
self._refresh_status()
|
|
||||||
self.add_toast(Adw.Toast(title=_("protonhax installed")))
|
|
||||||
except OSError as e:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Error: %s") % e))
|
|
||||||
|
|
||||||
def _on_lang_changed(self, row: Adw.ComboRow, _pspec) -> None:
|
|
||||||
langs = ["system", "ru_RU"]
|
|
||||||
self._config.ce_language = langs[row.get_selected()]
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def _on_install_localization(self, btn: Gtk.Button) -> None:
|
|
||||||
if not self._config.ce_exists:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Cheat Engine not found")))
|
|
||||||
return
|
|
||||||
|
|
||||||
btn.set_sensitive(False)
|
|
||||||
self._loc_row.set_subtitle(_("Downloading..."))
|
|
||||||
|
|
||||||
def worker() -> None:
|
|
||||||
ok = cheatengine.install_localization(self._config.ce_executable_path)
|
|
||||||
GLib.idle_add(self._on_localization_done, ok, btn)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _on_localization_done(self, success: bool, btn: Gtk.Button) -> None:
|
|
||||||
btn.set_sensitive(True)
|
|
||||||
self._refresh_status()
|
|
||||||
if success:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Localization installed")))
|
|
||||||
else:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Failed to install localization")))
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
# CE Download
|
|
||||||
# ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _on_check_release(self, _btn: Gtk.Button) -> None:
|
|
||||||
self._dl_row.set_subtitle(_("Checking..."))
|
|
||||||
self._check_btn.set_sensitive(False)
|
|
||||||
|
|
||||||
def worker() -> None:
|
|
||||||
info = cheatengine.get_latest_release()
|
|
||||||
GLib.idle_add(self._on_release_checked, info)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _on_release_checked(self, info: dict | None) -> None:
|
|
||||||
self._check_btn.set_sensitive(True)
|
|
||||||
|
|
||||||
if not info or not info.get("url"):
|
|
||||||
self._dl_row.set_subtitle(_("Failed to get info"))
|
|
||||||
return
|
|
||||||
|
|
||||||
self._release_info = info
|
|
||||||
version = info.get("version", "?")
|
|
||||||
size_mb = info.get("size", 0) / 1_048_576
|
|
||||||
|
|
||||||
subtitle = f"v{version}"
|
|
||||||
if size_mb > 0:
|
|
||||||
subtitle += f" ({size_mb:.1f} " + _("MB") + ")"
|
|
||||||
self._dl_row.set_subtitle(subtitle)
|
|
||||||
|
|
||||||
# Replace check button with download button
|
|
||||||
self._dl_row.remove(self._check_btn)
|
|
||||||
dl_btn = Gtk.Button(
|
|
||||||
label=_("Download"),
|
|
||||||
valign=Gtk.Align.CENTER,
|
|
||||||
css_classes=["suggested-action"],
|
|
||||||
)
|
|
||||||
dl_btn.connect("clicked", self._on_download_ce)
|
|
||||||
self._dl_row.add_suffix(dl_btn)
|
|
||||||
self._download_btn = dl_btn
|
|
||||||
|
|
||||||
def _on_download_ce(self, btn: Gtk.Button) -> None:
|
|
||||||
if not self._release_info:
|
|
||||||
return
|
|
||||||
|
|
||||||
url = self._release_info["url"]
|
|
||||||
name = self._release_info.get("name", "CheatEngine.exe")
|
|
||||||
|
|
||||||
cache_dir = Path.home() / ".cache" / "vual"
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
installer = cache_dir / name
|
|
||||||
|
|
||||||
dest_dir = Path.home() / ".local" / "share" / "vual" / "cheatengine"
|
|
||||||
|
|
||||||
self._progress_row.set_visible(True)
|
|
||||||
self._progress_row.set_subtitle(_("Downloading: %s") % name)
|
|
||||||
self._progress.set_fraction(0)
|
|
||||||
btn.set_sensitive(False)
|
|
||||||
|
|
||||||
def update_progress(frac: float) -> None:
|
|
||||||
GLib.idle_add(self._progress.set_fraction, frac)
|
|
||||||
|
|
||||||
def worker() -> None:
|
|
||||||
ok = cheatengine.download_file(url, installer, progress_cb=update_progress)
|
|
||||||
if not ok:
|
|
||||||
GLib.idle_add(self._download_failed, btn)
|
|
||||||
return
|
|
||||||
GLib.idle_add(self._start_extraction, installer, dest_dir, btn)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _download_failed(self, btn: Gtk.Button) -> None:
|
|
||||||
self._progress_row.set_visible(False)
|
|
||||||
btn.set_sensitive(True)
|
|
||||||
self.add_toast(Adw.Toast(title=_("Download failed")))
|
|
||||||
|
|
||||||
def _start_extraction(self, installer: Path, dest_dir: Path, btn: Gtk.Button) -> None:
|
|
||||||
self._progress_row.set_subtitle(_("Extracting..."))
|
|
||||||
self._progress.set_fraction(0.5)
|
|
||||||
|
|
||||||
def worker() -> None:
|
|
||||||
extracted = cheatengine.extract_installer(
|
|
||||||
installer, dest_dir, self._config.steam_path
|
|
||||||
)
|
|
||||||
exe = cheatengine.find_executable(dest_dir) if extracted else None
|
|
||||||
GLib.idle_add(self._extraction_done, exe, extracted, installer, btn)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _extraction_done(
|
|
||||||
self,
|
|
||||||
exe_path: Path | None,
|
|
||||||
extracted: bool,
|
|
||||||
installer: Path,
|
|
||||||
btn: Gtk.Button,
|
|
||||||
) -> None:
|
|
||||||
self._progress_row.set_visible(False)
|
|
||||||
btn.set_sensitive(True)
|
|
||||||
|
|
||||||
if exe_path:
|
|
||||||
home = str(Path.home())
|
|
||||||
display = str(exe_path).replace(home, "~", 1)
|
|
||||||
self._config.ce_executable = display
|
|
||||||
self._ce_path_row.set_text(display)
|
|
||||||
self._save()
|
|
||||||
self._refresh_status()
|
|
||||||
installer.unlink(missing_ok=True)
|
|
||||||
self.add_toast(Adw.Toast(title=_("Cheat Engine installed")))
|
|
||||||
elif extracted:
|
|
||||||
self._refresh_status()
|
|
||||||
self.add_toast(Adw.Toast(title=_("Extracted — specify path manually")))
|
|
||||||
else:
|
|
||||||
self.add_toast(Adw.Toast(title=_("Extraction failed")))
|
|
||||||
|
|||||||
@@ -153,178 +153,12 @@ class VualWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def show_guide(self) -> None:
|
def show_guide(self) -> None:
|
||||||
"""Show fullscreen guide page."""
|
"""Show fullscreen guide page."""
|
||||||
guide_page = self._build_guide_page()
|
from vual.ui.guide_page import build_guide_page
|
||||||
|
|
||||||
|
def _open_preferences():
|
||||||
|
app = self.get_application()
|
||||||
|
if app is not None:
|
||||||
|
app.activate_action("preferences", None)
|
||||||
|
|
||||||
|
guide_page = build_guide_page(self._nav_view, self.config, _open_preferences)
|
||||||
self._nav_view.push(guide_page)
|
self._nav_view.push(guide_page)
|
||||||
|
|
||||||
def _build_guide_page(self) -> Adw.NavigationPage:
|
|
||||||
"""Build the guide/onboarding page."""
|
|
||||||
toolbar_view = Adw.ToolbarView()
|
|
||||||
|
|
||||||
header = Adw.HeaderBar()
|
|
||||||
header.add_css_class("flat")
|
|
||||||
toolbar_view.add_top_bar(header)
|
|
||||||
|
|
||||||
scroll = Gtk.ScrolledWindow(vexpand=True)
|
|
||||||
toolbar_view.set_content(scroll)
|
|
||||||
|
|
||||||
clamp = Adw.Clamp(maximum_size=760)
|
|
||||||
scroll.set_child(clamp)
|
|
||||||
|
|
||||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
|
|
||||||
content.add_css_class("guide-page")
|
|
||||||
content.set_margin_top(28)
|
|
||||||
content.set_margin_bottom(40)
|
|
||||||
content.set_margin_start(24)
|
|
||||||
content.set_margin_end(24)
|
|
||||||
clamp.set_child(content)
|
|
||||||
|
|
||||||
hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
||||||
hero.add_css_class("guide-hero")
|
|
||||||
content.append(hero)
|
|
||||||
|
|
||||||
eyebrow = Gtk.Label(label=_("Getting Started"), xalign=0)
|
|
||||||
eyebrow.add_css_class("guide-eyebrow")
|
|
||||||
hero.append(eyebrow)
|
|
||||||
|
|
||||||
title = Gtk.Label(label=_("Set up Vual in a couple of minutes"), xalign=0)
|
|
||||||
title.add_css_class("guide-title")
|
|
||||||
title.set_wrap(True)
|
|
||||||
hero.append(title)
|
|
||||||
|
|
||||||
subtitle = Gtk.Label(
|
|
||||||
label=_("Vual links Steam, Proton and Cheat Engine so you can launch the game first and open CE in the right prefix without manual setup every time."),
|
|
||||||
xalign=0,
|
|
||||||
wrap=True,
|
|
||||||
)
|
|
||||||
subtitle.add_css_class("guide-subtitle")
|
|
||||||
hero.append(subtitle)
|
|
||||||
|
|
||||||
prep_card = self._build_guide_card(
|
|
||||||
_("Before you start"),
|
|
||||||
_("Make sure the base tools are ready before enabling games."),
|
|
||||||
[
|
|
||||||
_("Install or download Cheat Engine in Settings"),
|
|
||||||
_("Check that the Steam path points to the correct library"),
|
|
||||||
_("Close Steam before changing launch options for many games"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
content.append(prep_card)
|
|
||||||
|
|
||||||
flow_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
|
|
||||||
flow_card.add_css_class("guide-card")
|
|
||||||
content.append(flow_card)
|
|
||||||
|
|
||||||
flow_title = Gtk.Label(label=_("How Vual works"), xalign=0)
|
|
||||||
flow_title.add_css_class("guide-card-title")
|
|
||||||
flow_card.append(flow_title)
|
|
||||||
|
|
||||||
flow_subtitle = Gtk.Label(
|
|
||||||
label=_("The usual workflow is short and always the same."),
|
|
||||||
xalign=0,
|
|
||||||
wrap=True,
|
|
||||||
)
|
|
||||||
flow_subtitle.add_css_class("guide-card-subtitle")
|
|
||||||
flow_card.append(flow_subtitle)
|
|
||||||
|
|
||||||
steps = [
|
|
||||||
(_("Pick a game"), _("Find the game in the library and enable the switch on its tile.")),
|
|
||||||
(_("Start it from Steam"), _("Steam launches the game with protonhax and prepares the correct environment.")),
|
|
||||||
(_("Open Cheat Engine"), _("Use the tile menu to launch CE directly for that running game.")),
|
|
||||||
(_("Optional: attach a table"), _("Bind a .CT file once and Vual will pass it to Cheat Engine automatically.")),
|
|
||||||
]
|
|
||||||
for index, (step_title, step_desc) in enumerate(steps, start=1):
|
|
||||||
flow_card.append(self._build_guide_step(index, step_title, step_desc))
|
|
||||||
|
|
||||||
tips_card = self._build_guide_card(
|
|
||||||
_("Good to know"),
|
|
||||||
_("A few things save time once the basics are configured."),
|
|
||||||
[
|
|
||||||
_("Use search in the header to filter large libraries quickly"),
|
|
||||||
_("The menu button on each tile contains CE actions and table binding"),
|
|
||||||
_("Window transparency can be toggled in Appearance settings"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
content.append(tips_card)
|
|
||||||
|
|
||||||
actions = Gtk.Box(spacing=12)
|
|
||||||
actions.set_halign(Gtk.Align.CENTER)
|
|
||||||
actions.set_margin_top(12)
|
|
||||||
|
|
||||||
prefs_btn = Gtk.Button(label=_("Open Settings"))
|
|
||||||
prefs_btn.add_css_class("pill")
|
|
||||||
prefs_btn.connect("clicked", self._on_guide_preferences)
|
|
||||||
actions.append(prefs_btn)
|
|
||||||
|
|
||||||
start_btn = Gtk.Button(label=_("Open Library"))
|
|
||||||
start_btn.add_css_class("suggested-action")
|
|
||||||
start_btn.add_css_class("pill")
|
|
||||||
start_btn.connect("clicked", self._on_guide_close)
|
|
||||||
actions.append(start_btn)
|
|
||||||
content.append(actions)
|
|
||||||
|
|
||||||
return Adw.NavigationPage(child=toolbar_view, title=_("Welcome to Vual"), tag="guide")
|
|
||||||
|
|
||||||
def _build_guide_card(self, title: str, subtitle: str, items: list[str]) -> Gtk.Box:
|
|
||||||
"""Build a guide card with bullet rows."""
|
|
||||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
|
||||||
card.add_css_class("guide-card")
|
|
||||||
|
|
||||||
title_label = Gtk.Label(label=title, xalign=0)
|
|
||||||
title_label.add_css_class("guide-card-title")
|
|
||||||
card.append(title_label)
|
|
||||||
|
|
||||||
subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
|
|
||||||
subtitle_label.add_css_class("guide-card-subtitle")
|
|
||||||
card.append(subtitle_label)
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
row = Gtk.Box(spacing=10)
|
|
||||||
row.add_css_class("guide-list-row")
|
|
||||||
|
|
||||||
bullet = Gtk.Label(label="•")
|
|
||||||
bullet.add_css_class("guide-bullet")
|
|
||||||
row.append(bullet)
|
|
||||||
|
|
||||||
item_label = Gtk.Label(label=item, xalign=0, wrap=True)
|
|
||||||
item_label.add_css_class("guide-list-text")
|
|
||||||
row.append(item_label)
|
|
||||||
card.append(row)
|
|
||||||
|
|
||||||
return card
|
|
||||||
|
|
||||||
def _build_guide_step(self, index: int, title: str, description: str) -> Gtk.Box:
|
|
||||||
"""Build a single step row for the guide."""
|
|
||||||
row = Gtk.Box(spacing=14)
|
|
||||||
row.add_css_class("guide-step")
|
|
||||||
|
|
||||||
badge = Gtk.Label(label=str(index))
|
|
||||||
badge.add_css_class("guide-step-badge")
|
|
||||||
badge.set_size_request(34, 34)
|
|
||||||
badge.set_valign(Gtk.Align.START)
|
|
||||||
row.append(badge)
|
|
||||||
|
|
||||||
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
|
|
||||||
|
|
||||||
title_label = Gtk.Label(label=title, xalign=0)
|
|
||||||
title_label.add_css_class("guide-step-title")
|
|
||||||
text_box.append(title_label)
|
|
||||||
|
|
||||||
desc_label = Gtk.Label(label=description, xalign=0, wrap=True)
|
|
||||||
desc_label.add_css_class("guide-step-description")
|
|
||||||
text_box.append(desc_label)
|
|
||||||
|
|
||||||
row.append(text_box)
|
|
||||||
return row
|
|
||||||
|
|
||||||
def _on_guide_close(self, _btn: Gtk.Button) -> None:
|
|
||||||
"""Close guide and mark as shown."""
|
|
||||||
self.config.guide_shown = True
|
|
||||||
self.config.save()
|
|
||||||
self._nav_view.pop()
|
|
||||||
|
|
||||||
def _on_guide_preferences(self, _btn: Gtk.Button) -> None:
|
|
||||||
"""Open preferences from the guide."""
|
|
||||||
self._on_guide_close(_btn)
|
|
||||||
app = self.get_application()
|
|
||||||
if app is not None:
|
|
||||||
app.activate_action("preferences", None)
|
|
||||||
|
|||||||