4 Commits
Author SHA1 Message Date
Cheviiot 5e5e79dc17 Update RU translation for 'Guide' strings
Replace informal anglicism translations in Russian locale: change msgstr for "Guide" from "Гайд" to "Справка" and for "Close guide" from "Закрыть гайд" to "Закрыть Справку". Also update the compiled vual.mo accordingly.
2026-03-29 19:01:48 +10:00
Cheviiot bf02c49412 Исправлено название репозитория Lumenara на Luma в инструкциях по установке 2026-03-19 13:36:16 +10:00
Cheviiot c3a00f469c v0.3.1: open tables folder, shortened menu, full language localization, screenshot height fix 2026-03-19 13:19:53 +10:00
Cheviiot d5f7e4dbfd v0.3.0: split modules, screenshots, localization, icons, version check 2026-03-19 12:48:05 +10:00
30 changed files with 1607 additions and 1384 deletions
+64 -56
View File
@@ -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 |
## Участие в разработке ## Участие в разработке
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 911 B

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

+28 -1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+22 -4
View File
@@ -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"
+18 -6
View File
@@ -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 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
Binary file not shown.
Binary file not shown.
+117
View File
@@ -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
+272
View File
@@ -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
+189
View File
@@ -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
+13 -384
View File
@@ -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)"))
+225
View File
@@ -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)
+460
View File
@@ -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"))
+174
View File
@@ -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()
+15 -757
View File
@@ -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")))
+8 -174
View File
@@ -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)