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.
Показывает библиотеку Steam, управляет protonhax и launch options, скачивает и
Vual — приложение для запуска [Cheat Engine](https://github.com/cheat-engine/cheat-engine) в Steam-играх, работающих через Proton.
Показывает библиотеку Steam, управляет [protonhax](https://github.com/jcnils/protonhax) и launch options, скачивает и
распаковывает CE автоматически, запускает его в нужном Wine-префиксе — всё из одного
окна.
@@ -35,12 +35,21 @@ Vual — приложение для запуска Cheat Engine в Steam-игр
- **Прозрачность окна** — полупрозрачный фон в стиле 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
```
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
```
@@ -55,12 +64,12 @@ cd vual
<details>
<summary><strong>Зависимости</strong></summary>
| Дистрибутив | Команда |
|---|---|
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` |
| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-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` |
| Дистрибутив | Команда |
|------------------|---------------------------------------------------------------------------------|
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` |
| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-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` |
Также нужен **Proton** (через Steam) — для распаковки CE и работы protonhax.
@@ -68,63 +77,62 @@ cd vual
## Использование
```
./vual
```
### Перед началом
1. При первом запуске откроется встроенный гайд
2. Перейди в **Настройки → Cheat Engine** — скачай и распакуй CE
3. Установи **protonhax** там же
4. Включи переключатель у нужной игры — Vual пропишет launch options
5. Нажми ▶ — игра запустится, CE откроется автоматически
- Установи или скачай Cheat Engine в настройках
- Проверь, что путь к Steam указывает на правильную библиотеку
- Закрой Steam перед массовым изменением launch options
### Как работает Vual
| Шаг | | |
|---|---|---|
| **1** | **Выбери игру** | Найди игру в библиотеке и включи переключатель на её плитке |
| **2** | **Запусти через Steam** | Steam запустит игру с protonhax и подготовит нужное окружение |
| **3** | **Открой Cheat Engine** | Используй меню плитки, чтобы запустить CE для работающей игры |
| **4** | **Привяжи таблицу** | Привяжи .CT-файл один раз — Vual будет передавать его в CE автоматически |
### Полезно знать
- Поиск в заголовке быстро фильтрует большие библиотеки
- Кнопка меню на каждой плитке содержит действия CE и привязку таблиц
- Прозрачность окна включается в настройках внешнего вида
## Управление
| | |
|---|---|
| Запуск CE | Клик по кнопке ▶ на плитке игры |
| Поиск | Клик по полю поиска в заголовке |
| Привязать таблицу | Правая кнопка мыши → Assign Table |
| Вкл/выкл protonhax | Переключатель на плитке игры |
| Массовое вкл/выкл | Кнопки Enable All / Disable All |
| Сортировка | Меню сортировки в заголовке |
| Настройки | Главное меню → Preferences |
| Гайд | Главное меню → Guide |
| Действие | Как |
|--------------------|-----------------------------------------|
| Запуск CE | Кнопка ▶ на плитке игры |
| Поиск | Поле поиска в заголовке |
| Привязать таблицу | Меню плитки → Привязать таблицу |
| Вкл/выкл protonhax| Переключатель на плитке |
| Массовое вкл/выкл | Включить все / Отключить все |
| Сортировка | Меню сортировки в заголовке |
| Настройки | ☰ → Настройки |
| Гайд | ☰ → Гайд |
## Настройки
| | | По умолчанию |
|---|---|---|
| Тема | Системная / Светлая / Тёмная | Системная |
| Язык | Авто / English / Русский | Авто |
| Размер плиток | Small / Medium / Large | Medium |
| Сортировка | По имени / По статусу | По имени |
| Прозрачность окна | вкл / выкл | выкл |
| Тема Wine | Тёмная / Светлая | |
| Язык CE | EN / RU | — |
| Параметр | Варианты | По умолчанию |
|--------------------|------------------------------|--------------|
| Тема | Системная / Светлая / Тёмная | Системная |
| Язык | Авто / Английский / Русский | Авто |
| Размер плиток | Маленький / Средний / Большой| Средний |
| Сортировка | По имени / По статусу | По имени |
| Прозрачность окна | вкл / выкл | выкл |
| Тема Wine | Системная / Тёмная / Светлая | Системная |
| Язык CE | Системный / Русский | Системный |
## Данные
`~/.config/vual/`
| | |
|---|---|
| config.json | Настройки |
`~/.local/share/vual/`
| | |
|---|---|
| cheatengine/ | Cheat Engine |
| bin/protonhax | protonhax |
| tables/ | .CT-таблицы и привязки |
| wine_prefix/ | Wine-префикс для тестового запуска CE |
`~/.cache/vual/`
| | |
|---|---|
| covers/ | Кэш обложек Steam |
| Путь | Содержимое |
|-----------------------------------|-------------------------------------|
| `~/.config/vual/config.json` | Настройки |
| `~/.local/share/vual/cheatengine/`| Cheat Engine |
| `~/.local/share/vual/bin/protonhax`| protonhax |
| `~/.local/share/vual/tables/` | .CT-таблицы и привязки |
| `~/.local/share/vual/wine_prefix/`| Wine-префикс для тестового запуска |
| `~/.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">
<caption>Steam game library with covers</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>
</screenshots>
@@ -82,6 +97,18 @@
</provides>
<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">
<description>
<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 ""
msgstr ""
"Project-Id-Version: vual 0.2.0\n"
"Project-Id-Version: vual 0.3.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-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"
msgstr "Launch game"
msgid "Launch Cheat Engine"
msgstr "Launch Cheat Engine"
msgid "Launch CE"
msgstr "Launch CE"
msgid "Assign table…"
msgstr "Assign table…"
msgstr "Assign…"
msgid "Remove table"
msgstr "Remove table"
msgid "Tables folder"
msgstr "Tables folder"
msgid "Select Cheat Engine table"
msgstr "Select Cheat Engine table"
@@ -577,3 +580,18 @@ msgstr "Update"
msgid "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 ""
msgstr ""
"Project-Id-Version: vual 0.2.0\n"
"Project-Id-Version: vual 0.3.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-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"
msgstr "Запустить игру"
msgid "Launch Cheat Engine"
msgstr "Запустить Cheat Engine"
msgid "Launch CE"
msgstr "Запуск CE"
msgid "Assign table…"
msgstr "Назначить таблицу…"
msgstr "Назначить…"
msgid "Remove table"
msgstr "Убрать таблицу"
msgid "Tables folder"
msgstr "Папка таблиц"
msgid "Select Cheat Engine table"
msgstr "Выберите таблицу Cheat Engine"
@@ -344,10 +347,10 @@ msgstr "О программе"
# Guide
msgid "Guide"
msgstr "Гайд"
msgstr "Справка"
msgid "Close guide"
msgstr "Закрыть гайд"
msgstr "Закрыть Справку"
msgid "Welcome to Vual"
msgstr "Добро пожаловать в Vual"
@@ -586,3 +589,12 @@ msgstr "Обновить"
msgid "Wine not found — install Proton"
msgstr "Wine не найден — установите Proton"
msgid "already installed"
msgstr "уже установлена"
msgid "English"
msgstr "Английский"
msgid "Русский"
msgstr "Русский"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "vual"
version = "0.2.0"
version = "0.3.1"
description = "Launch Cheat Engine for Steam games via Proton"
readme = "README.md"
license = "GPL-3.0-or-later"
+1 -1
View File
@@ -2,7 +2,7 @@
from pathlib import Path
__version__ = "0.2.0"
__version__ = "0.3.1"
APP_ID = "io.github.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.
Clean rewrite using Gtk.FlowBox + simple Gtk.Overlay tiles.
"""
"""Main games grid page."""
from __future__ import annotations
import subprocess
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import TYPE_CHECKING
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 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
from vual.config import TILE_SIZES, Config
from vual.i18n import _
from vual import protonhax, steam, tables # noqa: E402
from vual.config import TILE_SIZES, Config # noqa: E402
from vual.i18n import _ # noqa: E402
from vual.ui.covers import cache_path, download_cover, load_pixbuf # noqa: E402
from vual.ui.game_tile import COVER_RATIO, GameTile # noqa: E402
if TYPE_CHECKING:
from vual.window import VualWindow
@@ -35,373 +28,8 @@ if TYPE_CHECKING:
# Constants
# ═══════════════════════════════════════════════════════════════════════════════
COVER_RATIO = 1.5 # Height = Width * 1.5 (portrait)
GRID_SPACING = 12 # Spacing between tiles
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
GRID_SPACING = 12
GRID_MARGIN = 16
# ═══════════════════════════════════════════════════════════════════════════════
@@ -634,9 +262,9 @@ class MainPage(Adw.Bin):
height = tile._height
def worker():
path = _download_cover(app_id)
path = download_cover(app_id)
if path:
pixbuf = _load_pixbuf(path, width, height)
pixbuf = load_pixbuf(path, width, height)
GLib.idle_add(tile.set_cover, pixbuf)
else:
GLib.idle_add(tile.set_cover, None)
@@ -650,7 +278,7 @@ class MainPage(Adw.Bin):
return
# Delete cached cover
cache = _cache_path(app_id)
cache = cache_path(app_id)
if cache.exists():
cache.unlink()
@@ -730,6 +358,7 @@ class MainPage(Adw.Bin):
"""Open file chooser to select a .CT table for *app_id*."""
dialog = Gtk.FileDialog()
dialog.set_title(_("Select Cheat Engine table"))
dialog.set_initial_folder(Gio.File.new_for_path(str(tables.TABLES_DIR)))
f = Gtk.FileFilter()
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.
"""
from __future__ import annotations
import threading
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
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
from vual.i18n import _
from vual.config import Config # noqa: E402
from vual.i18n import _ # noqa: E402
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):
@@ -30,12 +29,9 @@ class PreferencesWindow(Adw.PreferencesWindow):
)
self._config = config
self._parent = kwargs.get("transient_for")
self._exclusion_rows: list[Adw.EntryRow] = []
self._release_info: dict | None = None
self._building = True # Prevent saves during initial build
self._building = True
self._build_pages()
self._refresh_status()
self._building = False
def _save(self) -> None:
@@ -44,756 +40,18 @@ class PreferencesWindow(Adw.PreferencesWindow):
return
self._config.save()
# ════════════════════════════════════════════════════════════════
# Pages
# ════════════════════════════════════════════════════════════════
def _show_toast(self, message: str) -> None:
"""Show a toast notification."""
toast = Adw.Toast(title=message, timeout=3)
self.add_toast(toast)
def _build_pages(self) -> None:
"""Build all preference pages."""
for build in (self._build_appearance_page, self._build_steam_page, self._build_ce_page):
page = build()
"""Build all preference pages from modules."""
for build in (build_appearance_page, build_steam_page, build_ce_page):
page = build(self)
page.connect("map", self._on_page_mapped)
self.add(page)
def _on_page_mapped(self, page: Adw.PreferencesPage) -> None:
"""Reset focus when switching pages to avoid auto-selecting EntryRow."""
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:
"""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)
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)