6 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
Cheviiot 659363c474 v0.2.0 2026-03-19 05:35:55 +10:00
АлександрandGitHub 8f21756df6 Update README.md 2026-03-19 02:50:38 +10:00
38 changed files with 2434 additions and 1320 deletions
+100 -53
View File
@@ -1,98 +1,145 @@
<p align="center">
<img src="data/Vual.png" alt="Vual" width="96">
<img src="data/Vual.png" alt="Vual" width="128">
</p>
<h1 align="center">Vual</h1>
<p align="center">
GTK4-приложение для запуска Cheat Engine в Steam-играх через Proton
Запуск Cheat Engine для Steam-игр через Proton
</p>
<p align="center">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0-green?style=flat-square" alt="GPL-3.0"></a>
<img src="https://img.shields.io/badge/python-3.11+-blue?style=flat-square" alt="Python 3.11+">
<img src="https://img.shields.io/badge/gtk-4-4a86cf?style=flat-square" alt="GTK 4">
<img src="https://img.shields.io/badge/libadwaita-1-3584e4?style=flat-square" alt="Libadwaita 1">
<img src="https://img.shields.io/badge/GTK-4-4a86cf?style=flat-square" alt="GTK 4">
<img src="https://img.shields.io/badge/Libadwaita-1-3584e4?style=flat-square" alt="Libadwaita">
<img src="https://img.shields.io/badge/Python-3.11+-3776ab?style=flat-square" alt="Python 3.11+">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-green?style=flat-square" alt="GPL-3.0"></a>
</p>
## Что это
Vual показывает библиотеку установленных Steam-игр в виде сетки с обложками и позволяет в один клик запустить игру, включить для неё [protonhax](https://github.com/jcnils/protonhax) и открыть Cheat Engine прямо внутри Proton-контекста запущенной игры.
## О проекте
Приложение самостоятельно скачивает и распаковывает Cheat Engine, управляет protonhax, настраивает тему Wine для всех Proton-префиксов и поддерживает русский язык — как собственного интерфейса, так и Cheat Engine.
Vual — приложение для запуска [Cheat Engine](https://github.com/cheat-engine/cheat-engine) в Steam-играх, работающих через Proton.
Показывает библиотеку Steam, управляет [protonhax](https://github.com/jcnils/protonhax) и launch options, скачивает и
распаковывает CE автоматически, запускает его в нужном Wine-префиксе — всё из одного
окна.
## Возможности
| | |
|---|---|
| 🎮 **Библиотека** | Сетка игр с обложками из Steam CDN, поиск, сортировка по имени и статусу |
| ▶️ **Запуск** | Запуск игры через Steam URI, автоматическое подключение CE к запущенной Proton-игре |
| 🔧 **protonhax** | Переключатель launch options для каждой игры и массовое включение/отключение |
| 📦 **Cheat Engine** | Скачивание с cheatengine.org, распаковка через Wine из Proton, тестовый запуск |
| 🎨 **Тема Wine** | Тёмная и светлая тема для всех Proton-префиксов разом |
| 🌐 **Локализация** | Язык приложения (EN/RU), язык CE, установка русской локализации CE |
| ⚙️ **Настройки** | Путь к Steam, шаблон LaunchOptions, regex-исключения, размер плитки |
- **Сетка игр** — обложки из Steam CDN, поиск в реальном времени, сортировка по имени или статусу
- **Запуск одним кликом** — игра стартует через Steam, CE подключается к нужному Proton-префиксу
- **Таблицы CE** — привязка .CT-файла к игре, автоматическое открытие вместе с CE
- **protonhax** — автоматическая установка, переключатель launch options для каждой игры, массовое вкл/выкл
- **Установка CE** — скачивание с cheatengine.org, распаковка через Wine из Proton
- **Тема Wine** — тёмная или светлая тема для всех Proton-префиксов разом
- **Локализация** — интерфейс EN/RU, язык CE, установка русской локализации из офиц. репозитория CE
- **Прозрачность окна** — полупрозрачный фон в стиле 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 luma https://github.com/Cheviiot/Luma.git
stplr refresh && stplr install vual
```
### Из исходников
```bash
```
git clone https://github.com/Cheviiot/vual.git
cd vual
./vual
```
### Stapler (ALT Linux)
```bash
stplr repo add lumenara https://github.com/Cheviiot/Lumenara
stplr refresh && stplr install vual
```
<details>
<summary><strong>Зависимости</strong></summary>
| Дистрибутив | Команда |
|---|---|
| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita` |
| Fedora | `dnf install python3-gobject gtk4 libadwaita` |
| Arch | `pacman -S python-gobject gtk4 libadwaita` |
| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1` |
| Дистрибутив | Команда |
|------------------|---------------------------------------------------------------------------------|
| 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.
Также нужен **Proton** (через Steam) — для распаковки CE и работы protonhax.
</details>
## Быстрый старт
## Использование
1. Запустите `./vual`
2. Откройте настройки (☰ → Настройки)
3. На вкладке **Cheat Engine** скачайте и распакуйте CE
4. Установите **protonhax** если он ещё не установлен
5. Включите переключатель у нужной игры — Vual пропишет launch options
6. Нажмите ▶ у игры — после запуска CE откроется автоматически
### Перед началом
## Структура данных
- Установи или скачай Cheat Engine в настройках
- Проверь, что путь к Steam указывает на правильную библиотеку
- Закрой Steam перед массовым изменением launch options
```
~/.config/vual/
└── config.json # Настройки приложения
### Как работает Vual
~/.local/share/vual/
├── cheatengine/ # Установленный Cheat Engine
├── bin/protonhax # Управляемый скрипт protonhax
└── wine_prefix/ # Префикс Wine для тестового запуска CE
| Шаг | | |
|---|---|---|
| **1** | **Выбери игру** | Найди игру в библиотеке и включи переключатель на её плитке |
| **2** | **Запусти через Steam** | Steam запустит игру с protonhax и подготовит нужное окружение |
| **3** | **Открой Cheat Engine** | Используй меню плитки, чтобы запустить CE для работающей игры |
| **4** | **Привяжи таблицу** | Привяжи .CT-файл один раз — Vual будет передавать его в CE автоматически |
~/.cache/vual/
└── covers/ # Кэш обложек Steam
```
### Полезно знать
- Поиск в заголовке быстро фильтрует большие библиотеки
- Кнопка меню на каждой плитке содержит действия CE и привязку таблиц
- Прозрачность окна включается в настройках внешнего вида
## Управление
| Действие | Как |
|--------------------|-----------------------------------------|
| Запуск CE | Кнопка ▶ на плитке игры |
| Поиск | Поле поиска в заголовке |
| Привязать таблицу | Меню плитки → Привязать таблицу |
| Вкл/выкл protonhax| Переключатель на плитке |
| Массовое вкл/выкл | Включить все / Отключить все |
| Сортировка | Меню сортировки в заголовке |
| Настройки | ☰ → Настройки |
| Гайд | ☰ → Гайд |
## Настройки
| Параметр | Варианты | По умолчанию |
|--------------------|------------------------------|--------------|
| Тема | Системная / Светлая / Тёмная | Системная |
| Язык | Авто / Английский / Русский | Авто |
| Размер плиток | Маленький / Средний / Большой| Средний |
| Сортировка | По имени / По статусу | По имени |
| Прозрачность окна | вкл / выкл | выкл |
| Тема Wine | Системная / Тёмная / Светлая | Системная |
| Язык CE | Системный / Русский | Системный |
## Данные
| Путь | Содержимое |
|-----------------------------------|-------------------------------------|
| `~/.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 |
## Участие в разработке
> **Это личный проект.** Vual создаётся одним человеком для собственного использования при помощи ИИ.
> Это личный проект. Vual создаётся одним человеком для собственного использования при помощи ИИ.
Код полностью открыт. Если хотите помочь — Pull Request и Issue приветствуются: исправление ошибок, новые функции, переводы, плагины.
Код полностью открыт. Если хотите помочь — Pull Request и Issue приветствуются:
исправление ошибок, новые функции, переводы.
## Лицензия
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

+103 -39
View File
@@ -1,36 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.vual</id>
<name>Vual</name>
<summary>Launch Cheat Engine for Steam games via Proton</summary>
<summary>Cheat Engine launcher for Steam + Proton</summary>
<summary xml:lang="ru">Запуск Cheat Engine для Steam-игр через Proton</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<developer id="io.github.cheviiot">
<name>Cheviiot</name>
</developer>
<description>
<p>
Vual is a GTK4/libadwaita application that lets you browse your Steam
library, toggle protonhax launch options per game, and run Cheat Engine
inside the Proton context of a running game — all from a single window.
Vual lets you run Cheat Engine inside any Steam game that uses Proton.
It reads your Steam library, manages protonhax launch options, downloads
and extracts Cheat Engine automatically, and launches CE in the correct
Wine prefix — all from one compact window.
</p>
<p xml:lang="ru">
Vual — приложение на GTK4/libadwaita, которое показывает библиотеку Steam,
позволяет включать protonhax для каждой игры и запускать Cheat Engine
внутри Proton-контекста запущенной игры — из одного окна.
Vual позволяет запускать Cheat Engine в любой Steam-игре, работающей
через Proton. Приложение читает библиотеку Steam, управляет launch
options для protonhax, автоматически скачивает и распаковывает Cheat
Engine и запускает CE в нужном Wine-префиксе — всё из одного окна.
</p>
<p>Key features:</p>
<p xml:lang="ru">Основные возможности:</p>
<ul>
<li>Game grid with covers from Steam CDN, search, and sorting</li>
<li>Per-game and batch protonhax launch option management</li>
<li>One-click game launch with automatic Cheat Engine attach</li>
<li>Download and extract Cheat Engine via Proton Wine</li>
<li>Dark and light Wine theme for all Proton prefixes</li>
<li>Application and Cheat Engine language switching (EN/RU)</li>
<li xml:lang="ru">Сетка игр с обложками из Steam CDN, поиск и сортировка</li>
<li xml:lang="ru">Управление launch options protonhax по играм и массово</li>
<li xml:lang="ru">Запуск игры с автоматическим подключением Cheat Engine</li>
<li xml:lang="ru">Скачивание и распаковка Cheat Engine через Wine из Proton</li>
<li xml:lang="ru">Тёмная и светлая тема Wine для всех Proton-префиксов</li>
<li xml:lang="ru">Переключение языка приложения и Cheat Engine (EN/RU)</li>
<li>Game grid with Steam CDN covers, real-time search, and sorting</li>
<li>One-click launch — game starts via Steam, CE attaches to its Proton prefix</li>
<li>Bind .CT cheat tables to games so they load together with CE</li>
<li>Per-game and bulk protonhax toggle with automatic launch option editing</li>
<li>Download CE from cheatengine.org and extract it through Proton Wine</li>
<li>Apply dark or light Wine theme to all Proton prefixes at once</li>
<li>Install the official Russian CE translation from the CE repository</li>
<li>Built-in quick-start guide on first launch</li>
<li xml:lang="ru">Сетка игр с обложками из Steam CDN, поиск в реальном времени, сортировка</li>
<li xml:lang="ru">Запуск одним кликом — игра стартует через Steam, CE подключается к её Proton-префиксу</li>
<li xml:lang="ru">Привязка .CT-таблиц к играм — открываются вместе с CE</li>
<li xml:lang="ru">Переключатель protonhax для каждой игры и массово, автоматическая правка launch options</li>
<li xml:lang="ru">Скачивание CE с cheatengine.org и распаковка через Wine из Proton</li>
<li xml:lang="ru">Тёмная или светлая тема Wine для всех Proton-префиксов разом</li>
<li xml:lang="ru">Установка официальной русской локализации CE из репозитория CE</li>
<li xml:lang="ru">Встроенный гайд при первом запуске</li>
</ul>
</description>
@@ -40,9 +55,33 @@
<url type="bugtracker">https://github.com/Cheviiot/vual/issues</url>
<url type="vcs-browser">https://github.com/Cheviiot/vual</url>
<provides>
<binary>vual</binary>
</provides>
<branding>
<color type="primary" scheme_preference="light">#62a0ea</color>
<color type="primary" scheme_preference="dark">#1a5fb4</color>
</branding>
<screenshots>
<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_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>
<requires>
<display_length compare="ge">360</display_length>
@@ -53,31 +92,56 @@
<control>keyboard</control>
</recommends>
<branding>
<color type="primary" scheme_preference="light">#62a0ea</color>
<color type="primary" scheme_preference="dark">#1a5fb4</color>
</branding>
<screenshots>
<screenshot type="default">
<caption>Steam game library</caption>
<caption xml:lang="ru">Библиотека игр Steam</caption>
<image>https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main.png</image>
</screenshot>
</screenshots>
<provides>
<binary>vual</binary>
</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>
<p xml:lang="ru">Привязка CE-таблиц, менеджер тем Wine, встроенный гайд, прозрачность окна, тема Adwyra, установка русской локализации CE.</p>
</description>
</release>
<release version="0.1.0" date="2026-03-17">
<description>
<p>First release.</p>
<p xml:lang="ru">Первый выпуск.</p>
<p>First release — game grid, protonhax management, CE download and launch.</p>
<p xml:lang="ru">Первый выпуск — сетка игр, управление protonhax, скачивание и запуск CE.</p>
</description>
</release>
</releases>
<content_rating type="oars-1.1" />
<developer id="io.github.cheviiot">
<name>Cheviiot</name>
</developer>
<keywords>
<keyword>cheatengine</keyword>
<keyword>cheat engine</keyword>
<keyword>steam</keyword>
<keyword>proton</keyword>
<keyword>wine</keyword>
<keyword>games</keyword>
<keyword xml:lang="ru">чит</keyword>
<keyword xml:lang="ru">игры</keyword>
</keywords>
<categories>
<category>Game</category>
<category>Utility</category>
</categories>
<custom>
<value key="GnomeSoftware::key-colors">[(98, 160, 234)]</value>
</custom>
</component>
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

+143 -3
View File
@@ -4,7 +4,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: vual 0.1.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"
@@ -106,6 +106,31 @@ msgstr "Theme applied to %d prefixes"
msgid "Error applying to %d prefixes"
msgstr "Error applying to %d prefixes"
# Visual Effects
msgid "Visual Effects"
msgstr "Visual Effects"
msgid "Shadows, transparency, and accent colors"
msgstr "Shadows, transparency, and accent colors"
msgid "Shadows"
msgstr "Shadows"
msgid "Enhanced tile shadows and hover lift"
msgstr "Enhanced tile shadows and hover lift"
msgid "Transparency"
msgstr "Transparency"
msgid "Glassmorphism effect on overlays"
msgstr "Glassmorphism effect on overlays"
msgid "Accent glow"
msgstr "Accent glow"
msgid "Glow effect on active elements"
msgstr "Glow effect on active elements"
# Steam page
msgid "Steam"
msgstr "Steam"
@@ -223,8 +248,23 @@ 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…"
msgid "Remove table"
msgstr "Remove table"
msgid "Tables folder"
msgstr "Tables folder"
msgid "Select Cheat Engine table"
msgstr "Select Cheat Engine table"
msgid "CE tables (*.CT)"
msgstr "CE tables (*.CT)"
msgid "Launching %s..."
msgstr "Launching %s..."
@@ -299,6 +339,91 @@ msgstr "Settings"
msgid "About"
msgstr "About"
# Guide
msgid "Guide"
msgstr "Guide"
msgid "Close guide"
msgstr "Close guide"
msgid "Welcome to Vual"
msgstr "Welcome to Vual"
msgid "Getting Started"
msgstr "Getting Started"
msgid "Set up Vual in a couple of minutes"
msgstr "Set up Vual in a couple of minutes"
msgid "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."
msgstr "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."
msgid "Before you start"
msgstr "Before you start"
msgid "Make sure the base tools are ready before enabling games."
msgstr "Make sure the base tools are ready before enabling games."
msgid "Install or download Cheat Engine in Settings"
msgstr "Install or download Cheat Engine in Settings"
msgid "Check that the Steam path points to the correct library"
msgstr "Check that the Steam path points to the correct library"
msgid "Close Steam before changing launch options for many games"
msgstr "Close Steam before changing launch options for many games"
msgid "How Vual works"
msgstr "How Vual works"
msgid "The usual workflow is short and always the same."
msgstr "The usual workflow is short and always the same."
msgid "Pick a game"
msgstr "Pick a game"
msgid "Find the game in the library and enable the switch on its tile."
msgstr "Find the game in the library and enable the switch on its tile."
msgid "Start it from Steam"
msgstr "Start it from Steam"
msgid "Steam launches the game with protonhax and prepares the correct environment."
msgstr "Steam launches the game with protonhax and prepares the correct environment."
msgid "Open Cheat Engine"
msgstr "Open Cheat Engine"
msgid "Use the tile menu to launch CE directly for that running game."
msgstr "Use the tile menu to launch CE directly for that running game."
msgid "Optional: attach a table"
msgstr "Optional: attach a table"
msgid "Bind a .CT file once and Vual will pass it to Cheat Engine automatically."
msgstr "Bind a .CT file once and Vual will pass it to Cheat Engine automatically."
msgid "Good to know"
msgstr "Good to know"
msgid "A few things save time once the basics are configured."
msgstr "A few things save time once the basics are configured."
msgid "Use search in the header to filter large libraries quickly"
msgstr "Use search in the header to filter large libraries quickly"
msgid "The menu button on each tile contains CE actions and table binding"
msgstr "The menu button on each tile contains CE actions and table binding"
msgid "Window transparency can be toggled in Appearance settings"
msgstr "Window transparency can be toggled in Appearance settings"
msgid "Open Settings"
msgstr "Open Settings"
msgid "Open Library"
msgstr "Open Library"
# CE page additional
msgid "All files"
msgstr "All files"
@@ -455,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"
+143 -3
View File
@@ -4,7 +4,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: vual 0.1.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"
@@ -54,6 +54,12 @@ msgstr "Язык интерфейса приложения"
msgid "Restart app to apply language"
msgstr "Перезапустите приложение для смены языка"
msgid "Transparent window"
msgstr "Прозрачность окна"
msgid "Semi-transparent background"
msgstr "Полупрозрачный фон"
msgid "Grid"
msgstr "Сетка"
@@ -106,6 +112,31 @@ msgstr "Тема применена к %d префиксам"
msgid "Error applying to %d prefixes"
msgstr "Ошибка применения к %d префиксам"
# Visual Effects
msgid "Visual Effects"
msgstr "Визуальные эффекты"
msgid "Shadows, transparency, and accent colors"
msgstr "Тени, прозрачность и акцентные цвета"
msgid "Shadows"
msgstr "Тени"
msgid "Enhanced tile shadows and hover lift"
msgstr "Усиленные тени и подъём при наведении"
msgid "Transparency"
msgstr "Прозрачность"
msgid "Glassmorphism effect on overlays"
msgstr "Эффект стекла на накладках"
msgid "Accent glow"
msgstr "Акцентное свечение"
msgid "Glow effect on active elements"
msgstr "Свечение активных элементов"
# Steam page
msgid "Steam"
msgstr "Steam"
@@ -223,8 +254,23 @@ msgstr "Обновить обложку"
msgid "Launch game"
msgstr "Запустить игру"
msgid "Launch Cheat Engine"
msgstr "Запустить Cheat Engine"
msgid "Launch CE"
msgstr "Запуск CE"
msgid "Assign table…"
msgstr "Назначить…"
msgid "Remove table"
msgstr "Убрать таблицу"
msgid "Tables folder"
msgstr "Папка таблиц"
msgid "Select Cheat Engine table"
msgstr "Выберите таблицу Cheat Engine"
msgid "CE tables (*.CT)"
msgstr "Таблицы CE (*.CT)"
msgid "Launching %s..."
msgstr "Запуск %s…"
@@ -299,6 +345,91 @@ msgstr "Настройки"
msgid "About"
msgstr "О программе"
# Guide
msgid "Guide"
msgstr "Справка"
msgid "Close guide"
msgstr "Закрыть Справку"
msgid "Welcome to Vual"
msgstr "Добро пожаловать в Vual"
msgid "Getting Started"
msgstr "Быстрый старт"
msgid "Set up Vual in a couple of minutes"
msgstr "Настройте Vual за пару минут"
msgid "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."
msgstr "Vual связывает Steam, Proton и Cheat Engine, чтобы вы могли сначала запускать игру, а затем открывать CE в нужном префиксе без ручной настройки каждый раз."
msgid "Before you start"
msgstr "Перед началом"
msgid "Make sure the base tools are ready before enabling games."
msgstr "Убедитесь, что базовые инструменты готовы, прежде чем включать игры."
msgid "Install or download Cheat Engine in Settings"
msgstr "Установите или скачайте Cheat Engine в настройках"
msgid "Check that the Steam path points to the correct library"
msgstr "Проверьте, что путь Steam указывает на правильную библиотеку"
msgid "Close Steam before changing launch options for many games"
msgstr "Закройте Steam перед изменением параметров запуска для множества игр"
msgid "How Vual works"
msgstr "Как работает Vual"
msgid "The usual workflow is short and always the same."
msgstr "Обычный сценарий короткий и всегда одинаковый."
msgid "Pick a game"
msgstr "Выберите игру"
msgid "Find the game in the library and enable the switch on its tile."
msgstr "Найдите игру в библиотеке и включите переключатель на её плитке."
msgid "Start it from Steam"
msgstr "Запустите её из Steam"
msgid "Steam launches the game with protonhax and prepares the correct environment."
msgstr "Steam запустит игру через protonhax и подготовит правильное окружение."
msgid "Open Cheat Engine"
msgstr "Откройте Cheat Engine"
msgid "Use the tile menu to launch CE directly for that running game."
msgstr "Используйте меню плитки, чтобы запустить CE прямо для этой запущенной игры."
msgid "Optional: attach a table"
msgstr "Необязательно: привяжите таблицу"
msgid "Bind a .CT file once and Vual will pass it to Cheat Engine automatically."
msgstr "Привяжите .CT файл один раз, и Vual будет автоматически передавать его в Cheat Engine."
msgid "Good to know"
msgstr "Полезно знать"
msgid "A few things save time once the basics are configured."
msgstr "Несколько вещей заметно экономят время после базовой настройки."
msgid "Use search in the header to filter large libraries quickly"
msgstr "Используйте поиск в заголовке, чтобы быстро фильтровать большие библиотеки"
msgid "The menu button on each tile contains CE actions and table binding"
msgstr "Кнопка меню на каждой плитке содержит действия CE и привязку таблиц"
msgid "Window transparency can be toggled in Appearance settings"
msgstr "Прозрачность окна можно включить или выключить в настройках внешнего вида"
msgid "Open Settings"
msgstr "Открыть настройки"
msgid "Open Library"
msgstr "Открыть библиотеку"
# CE page additional
msgid "Actions"
msgstr "Действия"
@@ -458,3 +589,12 @@ msgstr "Обновить"
msgid "Wine not found — install Proton"
msgstr "Wine не найден — установите Proton"
msgid "already installed"
msgstr "уже установлена"
msgid "English"
msgstr "Английский"
msgid "Русский"
msgstr "Русский"
+38 -1
View File
@@ -6,7 +6,7 @@
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: vual 0.1.0\n"
"Project-Id-Version: vual 0.2.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-19 00:00+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
@@ -101,6 +101,31 @@ msgstr ""
msgid "Error applying to %d prefixes"
msgstr ""
# Visual Effects
msgid "Visual Effects"
msgstr ""
msgid "Shadows, transparency, and accent colors"
msgstr ""
msgid "Shadows"
msgstr ""
msgid "Enhanced tile shadows and hover lift"
msgstr ""
msgid "Transparency"
msgstr ""
msgid "Glassmorphism effect on overlays"
msgstr ""
msgid "Accent glow"
msgstr ""
msgid "Glow effect on active elements"
msgstr ""
# Steam page
msgid "Steam"
msgstr ""
@@ -224,6 +249,18 @@ msgstr ""
msgid "Launch Cheat Engine"
msgstr ""
msgid "Assign table…"
msgstr ""
msgid "Remove table"
msgstr ""
msgid "Select Cheat Engine table"
msgstr ""
msgid "CE tables (*.CT)"
msgstr ""
msgid "Launching %s..."
msgstr ""
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "vual"
version = "0.1.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.1.0"
__version__ = "0.3.1"
APP_ID = "io.github.vual"
APP_NAME = "Vual"
+1 -1
View File
@@ -7,7 +7,7 @@ import sys
def _ensure_gi_available():
"""Add system site-packages to path if gi is not importable (e.g., in venv)."""
try:
import gi # noqa: F401
__import__("gi")
return
except ImportError:
pass
+14
View File
@@ -39,6 +39,10 @@ class VualApp(Adw.Application):
win.set_default_size(self.config.window_width, self.config.window_height)
win.present()
# Show guide on first run
if not self.config.guide_shown:
win.show_guide()
# ── Startup: register actions ────────────────────────────────
def do_startup(self) -> None:
@@ -67,6 +71,10 @@ class VualApp(Adw.Application):
prefs_action.connect("activate", self._on_preferences)
self.add_action(prefs_action)
guide_action = Gio.SimpleAction.new("guide", None)
guide_action.connect("activate", self._on_guide)
self.add_action(guide_action)
quit_action = Gio.SimpleAction.new("quit", None)
quit_action.connect("activate", lambda *_: self.quit())
self.add_action(quit_action)
@@ -97,3 +105,9 @@ class VualApp(Adw.Application):
win = PreferencesWindow(config=self.config, transient_for=self.props.active_window)
win.present()
# ── Guide ────────────────────────────────────────────────────
def _on_guide(self, _action: Gio.SimpleAction, _param: None) -> None:
if self.props.active_window:
self.props.active_window.show_guide()
+8
View File
@@ -37,6 +37,8 @@ class Config:
ce_language: CE language ("system", "ru_RU").
wine_theme: Wine color theme ("system", "dark", "light").
app_language: Application UI language ("system", "en", "ru").
transparent_window: Enable semi-transparent window background.
guide_shown: Whether the startup guide has been shown.
"""
ce_executable: str = "~/.local/share/vual/cheatengine/cheatengine-x86_64.exe"
@@ -52,6 +54,8 @@ class Config:
ce_language: str = "system"
wine_theme: str = "system"
app_language: str = "system"
transparent_window: bool = False
guide_shown: bool = False
# ════════════════════════════════════════════════════════════════
# Load / Save
@@ -81,6 +85,8 @@ class Config:
ce_language=data.get("ce_language", cls.ce_language),
wine_theme=data.get("wine_theme", cls.wine_theme),
app_language=data.get("app_language", cls.app_language),
transparent_window=data.get("transparent_window", cls.transparent_window),
guide_shown=data.get("guide_shown", cls.guide_shown),
)
except (json.JSONDecodeError, OSError):
pass
@@ -107,6 +113,8 @@ class Config:
"ce_language": self.ce_language,
"wine_theme": self.wine_theme,
"app_language": self.app_language,
"transparent_window": self.transparent_window,
"guide_shown": self.guide_shown,
}
CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return CONFIG_PATH
+260 -70
View File
@@ -1,52 +1,69 @@
/* ═══════════════════════════════════════════════════════════════════════════
Vual — Unified Size System
Base unit: 4px
All sizes are multiples of 4px for consistency
Vual — Adwyra-inspired theme
Minimalist, clean design with soft shadows and accent highlights
═══════════════════════════════════════════════════════════════════════════ */
/* ── Transparent Window ────────────────────────────────────────────────── */
/* ═══════════════════════════════════════════════════════════════════════════
HEADER BAR
═══════════════════════════════════════════════════════════════════════════ */
window.transparent-window {
background-color: alpha(@window_bg_color, 0.82);
}
/* ── Header Bar ───────────────────────────────────────────────────────────── */
/* Header buttons: 32x32 (8 units) */
.header-btn {
min-width: 32px;
min-height: 32px;
padding: 0;
margin: 0;
border-radius: 6px;
border-radius: 10px;
transition: background-color 150ms ease-out, box-shadow 150ms ease-out;
}
.header-btn:hover {
background: alpha(@accent_bg_color, 0.12);
background-color: alpha(@accent_color, 0.08);
box-shadow: 0 2px 6px alpha(black, 0.06);
}
.header-btn:active {
background: alpha(@accent_bg_color, 0.2);
background-color: alpha(@accent_color, 0.16);
box-shadow: none;
}
/* Games counter */
.games-counter {
font-size: 0.85em;
font-weight: 500;
opacity: 0.6;
font-size: 0.84em;
font-weight: 450;
opacity: 0.65;
margin-left: 8px;
}
/* Search entry */
/* ── Search Entry ─────────────────────────────────────────────────────────── */
searchentry {
min-width: 220px;
border-radius: 14px;
padding: 1px 10px;
background-color: alpha(@view_bg_color, 0.3);
border: 1px solid alpha(@borders, 0.1);
box-shadow: 0 1px 3px alpha(black, 0.04);
transition: box-shadow 200ms ease-out, background-color 200ms ease-out,
border-color 200ms ease-out;
}
/* ═══════════════════════════════════════════════════════════════════════════
GRID LAYOUT
═══════════════════════════════════════════════════════════════════════════ */
.vual-grid {
/* Padding handled in Python via set_margin_* */
searchentry:focus {
background-color: @view_bg_color;
border-color: alpha(@accent_color, 0.35);
box-shadow: 0 0 0 3px alpha(@accent_color, 0.15);
}
searchentry image {
opacity: 0.5;
}
searchentry:focus image {
opacity: 0.75;
}
/* ── Grid Layout ──────────────────────────────────────────────────────────── */
flowboxchild {
padding: 0;
margin: 0;
@@ -64,49 +81,47 @@ flowboxchild:selected {
box-shadow: none;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE
═══════════════════════════════════════════════════════════════════════════ */
/* ── Tile ─────────────────────────────────────────────────────────────────── */
/* Tile container: 12px radius */
.vual-tile {
border-radius: 12px;
border-radius: 14px;
background: @card_bg_color;
box-shadow: 0 1px 2px alpha(black, 0.08);
box-shadow: 0 1px 3px alpha(black, 0.10), 0 0 0 1px alpha(black, 0.04);
transition: box-shadow 150ms ease-out;
}
.vual-tile:hover {
box-shadow: 0 2px 8px alpha(black, 0.15);
box-shadow: 0 4px 12px alpha(black, 0.14), 0 0 0 1px alpha(black, 0.05);
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE INFO OVERLAY (bottom)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Tile Info Overlay ────────────────────────────────────────────────────── */
/* Info panel: gradient background */
.vual-tile-info {
padding: 24px 8px 8px 8px;
padding: 20px 8px 8px 8px;
background: linear-gradient(to bottom,
transparent 0%,
alpha(black, 0.6) 50%,
alpha(black, 0.8) 100%);
border-radius: 0 0 12px 12px;
alpha(black, 0.55) 50%,
alpha(black, 0.75) 100%);
border-radius: 0 0 14px 14px;
}
.vual-tile-info label {
color: white;
transition: opacity 150ms ease-out;
}
.vual-tile-info .heading {
font-weight: 600;
font-size: 0.85em;
font-weight: 500;
font-size: 0.84em;
opacity: 0.9;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE BUTTONS (play, CE)
═══════════════════════════════════════════════════════════════════════════ */
.vual-tile:hover .vual-tile-info .heading {
opacity: 1;
}
/* ── Tile Buttons ─────────────────────────────────────────────────────────── */
/* Tile buttons: 28x28 (7 units) */
.tile-btn,
.vual-tile-info button {
min-width: 28px;
@@ -114,28 +129,47 @@ flowboxchild:selected {
padding: 0;
margin: 0;
color: white;
background: alpha(white, 0.12);
border-radius: 6px;
background: alpha(white, 0.08);
border-radius: 8px;
border: none;
transition: background-color 150ms ease-out, box-shadow 150ms ease-out;
}
.tile-btn:hover,
.vual-tile-info button:hover {
background: alpha(white, 0.24);
background: alpha(white, 0.18);
box-shadow: 0 2px 6px alpha(black, 0.12);
}
.tile-btn:active,
.vual-tile-info button:active {
background: alpha(white, 0.32);
background: alpha(white, 0.26);
box-shadow: none;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE SWITCH
═══════════════════════════════════════════════════════════════════════════ */
/* Popover menus — compact */
popover contents {
padding: 4px;
}
popover modelbutton {
min-height: 22px;
padding: 2px 8px;
font-size: 0.88em;
border-radius: 6px;
transition: background-color 100ms ease-out;
}
popover modelbutton:hover {
background-color: alpha(@accent_color, 0.1);
}
/* ── Tile Switch ──────────────────────────────────────────────────────────── */
.vual-tile-info switch {
background: alpha(white, 0.15);
background: alpha(white, 0.12);
border: none;
transition: background-color 150ms ease-out, box-shadow 150ms ease-out;
}
.vual-tile-info switch:checked {
@@ -146,50 +180,206 @@ flowboxchild:selected {
background: white;
min-width: 16px;
min-height: 16px;
border-radius: 8px;
border-radius: 50%;
box-shadow: 0 1px 3px alpha(black, 0.18);
}
/* ═══════════════════════════════════════════════════════════════════════════
RELOAD BUTTON (top-left corner)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Reload Button ────────────────────────────────────────────────────────── */
/* Reload: 24x24 (6 units) */
.vual-reload-btn {
min-width: 24px;
min-height: 24px;
padding: 0;
margin: 0;
border-radius: 6px;
background: alpha(black, 0.5);
border-radius: 50%;
background: alpha(black, 0.35);
color: white;
opacity: 0;
border: none;
transition: opacity 200ms ease-out, background-color 200ms ease-out;
}
.vual-tile:hover .vual-reload-btn {
opacity: 1;
opacity: 0.7;
}
.vual-reload-btn:hover {
background: @accent_bg_color;
opacity: 1;
background: alpha(@accent_color, 0.75);
}
/* ═══════════════════════════════════════════════════════════════════════════
RUNNING BADGE (top-right corner)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Running Badge ────────────────────────────────────────────────────────── */
/* Badge: 20px total (14px icon + 3px padding each side) */
.vual-badge {
background: @success_bg_color;
min-width: 12px;
min-height: 12px;
background: radial-gradient(circle at 35% 35%, alpha(white, 0.95) 0%, alpha(@accent_color, 0.95) 32%, @accent_color 68%, alpha(@accent_color, 0.88) 100%);
border-radius: 50%;
padding: 3px;
border: 1px solid alpha(white, 0.45);
box-shadow: 0 0 0 1px alpha(@accent_color, 0.16), 0 0 8px alpha(@accent_color, 0.55), 0 0 16px alpha(@accent_color, 0.32), 0 2px 6px alpha(black, 0.22);
}
/* ═══════════════════════════════════════════════════════════════════════════
SKELETON (loading placeholder)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
.skeleton {
background: alpha(@card_shade_color, 0.2);
background: alpha(@card_shade_color, 0.15);
border-radius: 14px;
}
/* ═══════════════════════════════════════════════════════════════════════════
GUIDE PAGE
═══════════════════════════════════════════════════════════════════════════ */
.guide-page {
margin: 0;
}
.guide-hero {
padding: 24px 24px 28px 24px;
border-radius: 24px;
background: linear-gradient(
180deg,
alpha(@accent_color, 0.12) 0%,
alpha(@accent_color, 0.04) 100%
);
border: 1px solid alpha(@accent_color, 0.08);
}
.guide-eyebrow {
font-size: 0.82em;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
}
.guide-title {
font-size: 2.1em;
font-weight: 800;
letter-spacing: -0.03em;
}
.guide-subtitle {
font-size: 1.02em;
opacity: 0.78;
line-height: 1.35;
}
.guide-card {
padding: 20px;
border-radius: 20px;
background: alpha(@view_bg_color, 0.55);
border: 1px solid alpha(@borders, 0.12);
box-shadow: 0 1px 3px alpha(black, 0.04);
}
.guide-card-title {
font-size: 1.08em;
font-weight: 700;
}
.guide-card-subtitle {
opacity: 0.7;
line-height: 1.35;
}
.guide-list-row {
margin-top: 2px;
}
.guide-bullet {
min-width: 12px;
opacity: 0.7;
}
.guide-list-text {
line-height: 1.35;
}
.guide-step {
padding: 10px 0;
}
.guide-step-badge {
border-radius: 999px;
background: alpha(@accent_color, 0.14);
color: @accent_color;
font-weight: 700;
}
.guide-step-title {
font-weight: 650;
}
.guide-step-description {
opacity: 0.72;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════════════════
PREFERENCES
═══════════════════════════════════════════════════════════════════════════ */
preferencesgroup > box > .header {
margin-top: 4px;
margin-bottom: 4px;
}
preferencesgroup > box > box.header > box.title > label.title {
font-weight: 600;
font-size: 0.84em;
letter-spacing: 0.04em;
opacity: 0.55;
text-transform: uppercase;
}
preferencesgroup > box > .header > label.description {
font-size: 0.8em;
}
/* Boxed lists */
.boxed-list {
border-radius: 12px;
border: 1px solid alpha(@borders, 0.12);
background-color: alpha(@view_bg_color, 0.45);
box-shadow: 0 1px 3px alpha(black, 0.04);
}
row {
border-radius: 10px;
margin: 0 4px;
padding: 0;
min-height: 40px;
transition: background-color 120ms ease-out;
}
row:hover {
background-color: alpha(@accent_color, 0.06);
}
/* ═══════════════════════════════════════════════════════════════════════════
UTILITY CLASSES
═══════════════════════════════════════════════════════════════════════════ */
.dimmed {
opacity: 0.45;
transition: opacity 150ms ease-out;
}
.dimmed:hover {
opacity: 0.9;
}
.success { color: @success_color; }
.warning { color: @warning_color; }
separator {
background-color: alpha(@borders, 0.18);
margin: 4px 0;
min-height: 1px;
}
tooltip {
border-radius: 8px;
padding: 4px 8px;
}
+1 -19
View File
@@ -23,7 +23,6 @@ _LOCALE_DIRS = [
# Global translator
_translator: gettext.GNUTranslations | gettext.NullTranslations | None = None
_current_lang: str = "system"
def _find_locale_dir() -> Path | None:
@@ -47,8 +46,7 @@ def init(lang: str = "system") -> None:
lang: Language code ("system", "en", "ru").
"system" uses system locale.
"""
global _translator, _current_lang
_current_lang = lang
global _translator
# Get system locale
try:
@@ -78,23 +76,8 @@ def init(lang: str = "system") -> None:
_translator = gettext.NullTranslations()
def set_language(lang: str) -> None:
"""Change application language.
Args:
lang: Language code ("system", "en", "ru").
"""
init(lang)
def get_current_language() -> str:
"""Get current language setting."""
return _current_lang
def _(message: str) -> str:
"""Translate a message string."""
global _translator
if _translator is None:
init()
return _translator.gettext(message) if _translator else message
@@ -102,7 +85,6 @@ def _(message: str) -> str:
def ngettext(singular: str, plural: str, n: int) -> str:
"""Translate a plural message."""
global _translator
if _translator is None:
init()
return _translator.ngettext(singular, plural, n) if _translator else (singular if n == 1 else plural)
Binary file not shown.
Binary file not shown.
+76
View File
@@ -0,0 +1,76 @@
"""Cheat Engine table (.CT) storage and per-game binding.
Tables are stored in ~/.local/share/vual/tables/.
Bindings (app_id → filename) are persisted in tables.json alongside the files.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from gi.repository import GLib
TABLES_DIR = Path(GLib.get_user_data_dir()) / "vual" / "tables"
_MAP_FILE = TABLES_DIR / "tables.json"
# ── Internal helpers ─────────────────────────────────────────────
def _load_map() -> dict[str, str]:
if _MAP_FILE.exists():
try:
return json.loads(_MAP_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_map(data: dict[str, str]) -> None:
TABLES_DIR.mkdir(parents=True, exist_ok=True)
_MAP_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
# ── Public API ───────────────────────────────────────────────────
def bind(app_id: str, src: str | Path) -> Path:
"""Copy a .CT file into the tables directory and bind it to *app_id*.
Returns the destination path inside TABLES_DIR.
"""
src = Path(src)
TABLES_DIR.mkdir(parents=True, exist_ok=True)
dest = TABLES_DIR / src.name
if dest.resolve() != src.resolve():
shutil.copy2(src, dest)
data = _load_map()
data[app_id] = dest.name
_save_map(data)
return dest
def unbind(app_id: str) -> None:
"""Remove the table binding for *app_id* (file kept on disk)."""
data = _load_map()
if app_id in data:
del data[app_id]
_save_map(data)
def get_table(app_id: str) -> Path | None:
"""Return the full path to the bound .CT file, or None."""
data = _load_map()
name = data.get(app_id)
if not name:
return None
path = TABLES_DIR / name
return path if path.is_file() else None
def get_table_name(app_id: str) -> str | None:
"""Return just the filename of the bound table, or None."""
data = _load_map()
return data.get(app_id)
+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
+51 -345
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, GLib, Gtk, Pango
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
import requests
from vual import protonhax, steam
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,323 +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
btn_ce = Gtk.Button()
btn_ce.set_icon_name("utilities-terminal-symbolic")
btn_ce.add_css_class("flat")
btn_ce.add_css_class("tile-btn")
btn_ce.set_tooltip_text(_("Launch Cheat Engine"))
btn_ce.connect("clicked", self._on_ce_clicked)
row.append(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.Image.new_from_icon_name("media-playback-start-symbolic")
self._badge.set_pixel_size(14)
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:
"""Emit CE signal."""
if hasattr(self, "_on_launch_ce"):
self._on_launch_ce(self._app_id, self._name)
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
GRID_SPACING = 12
GRID_MARGIN = 16
# ═══════════════════════════════════════════════════════════════════════════════
@@ -473,11 +151,6 @@ class MainPage(Adw.Bin):
for app_id, tile in self._tiles.items():
tile.set_running(app_id in running_ids)
@property
def games_count(self) -> int:
"""Number of loaded games."""
return len(self._tiles)
# ─── Filter & Sort ────────────────────────────────────────────────────────
def _filter_func(self, child: Gtk.FlowBoxChild) -> bool:
@@ -567,6 +240,7 @@ class MainPage(Adw.Bin):
tile.connect_launch_ce(self._on_launch_ce)
tile.connect_toggle(self._on_toggle)
tile.connect_reload(self._on_reload_cover)
tile.connect_pick_table(self._on_pick_table)
self._tiles[app_id] = tile
self._grid.append(tile)
@@ -588,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)
@@ -604,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()
@@ -666,16 +340,51 @@ class MainPage(Adw.Bin):
self._win.show_toast(_("CE not found: %s") % self._cfg.ce_executable)
return
args = None
args: list[str] = []
if self._cfg.ce_language != "system":
args = ["--LANG", self._cfg.ce_language]
args.extend(["--LANG", self._cfg.ce_language])
proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args)
ct = tables.get_table(app_id)
if ct:
args.append(str(ct))
proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args or None)
if proc:
self._win.show_toast(_("CE launched for %s") % name)
else:
self._win.show_toast(_("Failed to launch CE for %s") % name)
def _on_pick_table(self, app_id: str) -> None:
"""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)"))
f.add_pattern("*.CT")
f.add_pattern("*.ct")
filters = Gio.ListStore.new(Gtk.FileFilter)
filters.append(f)
dialog.set_filters(filters)
dialog.open(self._win, None, self._on_table_chosen, app_id)
def _on_table_chosen(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult, app_id: str) -> None:
try:
gfile = dialog.open_finish(result)
except GLib.Error:
return
if not gfile:
return
path = gfile.get_path()
if not path:
return
tables.bind(app_id, path)
tile = self._tiles.get(app_id)
if tile:
tile.mark_table_bound()
def _on_toggle(self, app_id: str, active: bool) -> None:
"""Handle activation toggle."""
tile = self._tiles.get(app_id)
@@ -811,6 +520,3 @@ class MainPage(Adw.Bin):
if restart:
steam.start_steam()
def schedule_auto_launch(self, app_id: str) -> None:
"""Schedule auto CE launch for a game."""
self._auto_ce = app_id
+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()
+16 -742
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):
@@ -29,12 +28,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
**kwargs,
)
self._config = config
self._exclusion_rows: list[Adw.EntryRow] = []
self._release_info: dict | None = None
self._building = True # Prevent saves during initial build
self._parent = kwargs.get("transient_for")
self._building = True
self._build_pages()
self._refresh_status()
self._building = False
def _save(self) -> None:
@@ -43,741 +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)
# 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 _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")))
+41 -5
View File
@@ -31,12 +31,23 @@ class VualWindow(Adw.ApplicationWindow):
self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay)
toolbar_view = Adw.ToolbarView()
self.toast_overlay.set_child(toolbar_view)
# Navigation view for guide/main switching
self._nav_view = Adw.NavigationView()
self.toast_overlay.set_child(self._nav_view)
# Single main page
# Main page
self._main_nav_page = self._build_main_page()
self._nav_view.add(self._main_nav_page)
# Apply transparency from config
self.apply_transparency()
def _build_main_page(self) -> Adw.NavigationPage:
"""Build the main games grid page."""
from vual.ui.main_page import MainPage
toolbar_view = Adw.ToolbarView()
self.main_page = MainPage(config=self.config, window=self)
# Header bar
@@ -102,7 +113,9 @@ class VualWindow(Adw.ApplicationWindow):
toolbar_view.set_content(self.main_page)
def _on_sort_changed(self, action: Gio.SimpleAction, param: GLib.Variant) -> None:
return Adw.NavigationPage(child=toolbar_view, title=APP_NAME, tag="main")
def _on_sort_changed(self, _action: Gio.SimpleAction, param: GLib.Variant) -> None:
sort_by = param.get_string()
self.config.sort_by = sort_by
self.main_page._update_sort()
@@ -117,7 +130,7 @@ class VualWindow(Adw.ApplicationWindow):
menu = Gio.Menu()
menu.append(_("Preferences"), "app.preferences")
menu.append(_("About"), "app.about")
menu.append(_("Quit"), "app.quit")
menu.append(_("Guide"), "app.guide")
return menu
# ── Toast helper ─────────────────────────────────────────────
@@ -126,3 +139,26 @@ class VualWindow(Adw.ApplicationWindow):
toast = Adw.Toast(title=message, timeout=timeout)
self.toast_overlay.add_toast(toast)
return False
# ── Transparency ──────────────────────────────────────────────
def apply_transparency(self) -> None:
"""Apply or remove transparent window CSS class."""
if self.config.transparent_window:
self.add_css_class("transparent-window")
else:
self.remove_css_class("transparent-window")
# ── Guide page ───────────────────────────────────────────────
def show_guide(self) -> None:
"""Show fullscreen 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)
-37
View File
@@ -367,40 +367,3 @@ def apply_theme_to_all(steamapps: Path, theme: str) -> tuple[int, int]:
return (success, failed)
def get_current_theme(pfx: Path) -> str | None:
"""Detect current theme from prefix colors.
Returns:
"dark", "light", or None if unknown.
"""
user_reg = pfx / "user.reg"
if not user_reg.is_file():
return None
try:
content = user_reg.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
# Check Window background color
match = re.search(r'"Window"="([^"]+)"', content)
if not match:
return None
window_color = match.group(1)
# Dark themes typically have dark Window color
parts = window_color.split()
if len(parts) == 3:
try:
r, g, b = int(parts[0]), int(parts[1]), int(parts[2])
# If all RGB < 100, it's dark
if r < 100 and g < 100 and b < 100:
return "dark"
# If all RGB > 200, it's light
if r > 200 and g > 200 and b > 200:
return "light"
except ValueError:
pass
return None