commit 66dffe050c2b356676711c67e16d4ea6aa37db36 Author: Александр <6terio9@vk.com> Date: Tue Mar 17 09:57:13 2026 +1000 v0.1.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8e93e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +.pycache/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Project +*.log +.cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e77243 --- /dev/null +++ b/LICENSE @@ -0,0 +1,40 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2026 Cheviiot + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + + + DETAILED TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "The Program" refers to any copyrightable work licensed under this + License. Each licensee is addressed as "you". "Licensees" and + "recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work + in a fashion requiring copyright permission, other than the making of an + exact copy. The resulting work is called a "modified version" of the + earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based + on the Program. + + The full text of the GNU General Public License version 3 is available at: + +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f94f1a --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +

+ Vual +

+ +

Vual

+ +

+ GTK4-приложение для запуска Cheat Engine в Steam-играх через Proton +

+ +

+ GPL-3.0 + Python 3.11+ + GTK 4 + Libadwaita 1 +

+ +## Что это + +Vual показывает библиотеку установленных Steam-игр в виде сетки с обложками и позволяет в один клик запустить игру, включить для неё [protonhax](https://github.com/jcnils/protonhax) и открыть Cheat Engine прямо внутри Proton-контекста запущенной игры. + +Приложение самостоятельно скачивает и распаковывает Cheat Engine, управляет protonhax, настраивает тему Wine для всех Proton-префиксов и поддерживает русский язык — как собственного интерфейса, так и Cheat Engine. + +## Возможности + +| | | +|---|---| +| 🎮 **Библиотека** | Сетка игр с обложками из Steam CDN, поиск, сортировка по имени и статусу | +| ▶️ **Запуск** | Запуск игры через Steam URI, автоматическое подключение CE к запущенной Proton-игре | +| 🔧 **protonhax** | Переключатель launch options для каждой игры и массовое включение/отключение | +| 📦 **Cheat Engine** | Скачивание с cheatengine.org, распаковка через Wine из Proton, тестовый запуск | +| 🎨 **Тема Wine** | Тёмная и светлая тема для всех Proton-префиксов разом | +| 🌐 **Локализация** | Язык приложения (EN/RU), язык CE, установка русской локализации CE | +| ⚙️ **Настройки** | Путь к Steam, шаблон LaunchOptions, regex-исключения, размер плитки | + +## Установка + +### Из исходников + +```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 +``` + +
+Зависимости + +| Дистрибутив | Команда | +|---|---| +| 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` | + +Также необходим **Proton** (установленный через Steam) — для распаковки CE и работы protonhax. + +
+ +## Быстрый старт + +1. Запустите `./vual` +2. Откройте настройки (☰ → Настройки) +3. На вкладке **Cheat Engine** скачайте и распакуйте CE +4. Установите **protonhax** если он ещё не установлен +5. Включите переключатель у нужной игры — Vual пропишет launch options +6. Нажмите ▶ у игры — после запуска CE откроется автоматически + +## Структура данных + +``` +~/.config/vual/ +└── config.json # Настройки приложения + +~/.local/share/vual/ +├── cheatengine/ # Установленный Cheat Engine +├── bin/protonhax # Управляемый скрипт protonhax +└── wine_prefix/ # Префикс Wine для тестового запуска CE + +~/.cache/vual/ +└── covers/ # Кэш обложек Steam +``` + +## Участие в разработке + +> **Это личный проект.** Vual создаётся одним человеком для собственного использования при помощи ИИ. + +Код полностью открыт. Если хотите помочь — Pull Request и Issue приветствуются: исправление ошибок, новые функции, переводы, плагины. + +## Лицензия + +[GPL-3.0-or-later](LICENSE) diff --git a/data/Vual.png b/data/Vual.png new file mode 100644 index 0000000..1faff04 Binary files /dev/null and b/data/Vual.png differ diff --git a/data/icons/hicolor/128x128/apps/io.github.vual.png b/data/icons/hicolor/128x128/apps/io.github.vual.png new file mode 100644 index 0000000..f74a814 Binary files /dev/null and b/data/icons/hicolor/128x128/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/16x16/apps/io.github.vual.png b/data/icons/hicolor/16x16/apps/io.github.vual.png new file mode 100644 index 0000000..e5c3dfd Binary files /dev/null and b/data/icons/hicolor/16x16/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/24x24/apps/io.github.vual.png b/data/icons/hicolor/24x24/apps/io.github.vual.png new file mode 100644 index 0000000..1485faa Binary files /dev/null and b/data/icons/hicolor/24x24/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/256x256/apps/io.github.vual.png b/data/icons/hicolor/256x256/apps/io.github.vual.png new file mode 100644 index 0000000..6c73813 Binary files /dev/null and b/data/icons/hicolor/256x256/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/32x32/apps/io.github.vual.png b/data/icons/hicolor/32x32/apps/io.github.vual.png new file mode 100644 index 0000000..22ff8bf Binary files /dev/null and b/data/icons/hicolor/32x32/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/48x48/apps/io.github.vual.png b/data/icons/hicolor/48x48/apps/io.github.vual.png new file mode 100644 index 0000000..3ceac1b Binary files /dev/null and b/data/icons/hicolor/48x48/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/512x512/apps/io.github.vual.png b/data/icons/hicolor/512x512/apps/io.github.vual.png new file mode 100644 index 0000000..9680d2b Binary files /dev/null and b/data/icons/hicolor/512x512/apps/io.github.vual.png differ diff --git a/data/icons/hicolor/64x64/apps/io.github.vual.png b/data/icons/hicolor/64x64/apps/io.github.vual.png new file mode 100644 index 0000000..f23ead6 Binary files /dev/null and b/data/icons/hicolor/64x64/apps/io.github.vual.png differ diff --git a/data/io.github.vual.desktop b/data/io.github.vual.desktop new file mode 100644 index 0000000..bee27e2 --- /dev/null +++ b/data/io.github.vual.desktop @@ -0,0 +1,16 @@ +[Desktop Entry] +Name=Vual +Name[ru]=Vual +GenericName=Cheat Engine Manager +GenericName[ru]=Менеджер Cheat Engine +Comment=Launch Cheat Engine for Steam games via Proton +Comment[ru]=Запуск Cheat Engine для Steam-игр через Proton +Exec=vual +Icon=io.github.vual +Terminal=false +Type=Application +Categories=Game;Utility; +Keywords=cheat;engine;steam;proton;wine;games;trainer; +Keywords[ru]=читы;движок;steam;proton;wine;игры;трейнер; +StartupNotify=true +StartupWMClass=vual diff --git a/data/io.github.vual.metainfo.xml b/data/io.github.vual.metainfo.xml new file mode 100644 index 0000000..476ce7c --- /dev/null +++ b/data/io.github.vual.metainfo.xml @@ -0,0 +1,83 @@ + + + io.github.vual + Vual + Launch Cheat Engine for Steam games via Proton + Запуск Cheat Engine для Steam-игр через Proton + CC0-1.0 + GPL-3.0-or-later + + +

+ 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 — приложение на GTK4/libadwaita, которое показывает библиотеку Steam, + позволяет включать protonhax для каждой игры и запускать Cheat Engine + внутри Proton-контекста запущенной игры — из одного окна. +

+
    +
  • Game grid with covers from Steam CDN, search, and sorting
  • +
  • Per-game and batch protonhax launch option management
  • +
  • One-click game launch with automatic Cheat Engine attach
  • +
  • Download and extract Cheat Engine via Proton Wine
  • +
  • Dark and light Wine theme for all Proton prefixes
  • +
  • Application and Cheat Engine language switching (EN/RU)
  • +
  • Сетка игр с обложками из Steam CDN, поиск и сортировка
  • +
  • Управление launch options protonhax по играм и массово
  • +
  • Запуск игры с автоматическим подключением Cheat Engine
  • +
  • Скачивание и распаковка Cheat Engine через Wine из Proton
  • +
  • Тёмная и светлая тема Wine для всех Proton-префиксов
  • +
  • Переключение языка приложения и Cheat Engine (EN/RU)
  • +
+
+ + io.github.vual.desktop + + https://github.com/Cheviiot/vual + https://github.com/Cheviiot/vual/issues + https://github.com/Cheviiot/vual + + + vual + + + + 360 + + + + pointing + keyboard + + + + #62a0ea + #1a5fb4 + + + + + Steam game library + Библиотека игр Steam + https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main.png + + + + + + +

First release.

+

Первый выпуск.

+
+
+
+ + + + + Cheviiot + +
diff --git a/po/compile.py b/po/compile.py new file mode 100644 index 0000000..4b4051b --- /dev/null +++ b/po/compile.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Compile .po files to .mo files for runtime use.""" + +import subprocess +import sys +from pathlib import Path + +PO_DIR = Path(__file__).parent +LOCALE_DIR = PO_DIR.parent / "src" / "vual" / "locale" + + +def compile_po(): + """Compile all .po files to .mo files.""" + for po_file in PO_DIR.glob("*.po"): + lang = po_file.stem + mo_dir = LOCALE_DIR / lang / "LC_MESSAGES" + mo_dir.mkdir(parents=True, exist_ok=True) + mo_file = mo_dir / "vual.mo" + + print(f"Compiling {po_file.name} -> {mo_file.relative_to(PO_DIR.parent)}") + + try: + subprocess.run( + ["msgfmt", "-o", str(mo_file), str(po_file)], + check=True, + ) + except FileNotFoundError: + print("Error: msgfmt not found. Install gettext:") + print(" sudo apt install gettext") + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"Error compiling {po_file.name}: {e}") + sys.exit(1) + + print("Done!") + + +if __name__ == "__main__": + compile_po() diff --git a/po/en.po b/po/en.po new file mode 100644 index 0000000..954e9be --- /dev/null +++ b/po/en.po @@ -0,0 +1,457 @@ +# English translation for Vual +# Copyright (C) 2026 Vual Authors +# This file is distributed under the same license as the Vual package. +# +msgid "" +msgstr "" +"Project-Id-Version: vual 0.1.0\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" +"Last-Translator: \n" +"Language-Team: English\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +# Window titles +msgid "Preferences" +msgstr "Preferences" + +msgid "About Vual" +msgstr "About Vual" + +# Appearance page +msgid "Appearance" +msgstr "Appearance" + +msgid "Theme" +msgstr "Theme" + +msgid "Color scheme" +msgstr "Color scheme" + +msgid "Light, dark, or system" +msgstr "Light, dark, or system" + +msgid "System" +msgstr "System" + +msgid "Light" +msgstr "Light" + +msgid "Dark" +msgstr "Dark" + +msgid "Language" +msgstr "Language" + +msgid "Application interface language" +msgstr "Application interface language" + +msgid "Restart app to apply language" +msgstr "Restart app to apply language" + +msgid "Grid" +msgstr "Grid" + +msgid "Game display settings" +msgstr "Game display settings" + +msgid "Tile size" +msgstr "Tile size" + +msgid "Cover size in library" +msgstr "Cover size in library" + +msgid "Small (120px)" +msgstr "Small (120px)" + +msgid "Medium (150px)" +msgstr "Medium (150px)" + +msgid "Large (180px)" +msgstr "Large (180px)" + +msgid "Sort" +msgstr "Sort" + +msgid "Default game order" +msgstr "Default game order" + +msgid "By name" +msgstr "By name" + +msgid "By status" +msgstr "By status" + +# Wine theme +msgid "Wine Theme" +msgstr "Wine Theme" + +msgid "Color scheme for Cheat Engine and other Wine apps" +msgstr "Color scheme for Cheat Engine and other Wine apps" + +msgid "Applied to all Proton prefixes" +msgstr "Applied to all Proton prefixes" + +msgid "Reapply theme" +msgstr "Reapply theme" + +msgid "Theme applied to %d prefixes" +msgstr "Theme applied to %d prefixes" + +msgid "Error applying to %d prefixes" +msgstr "Error applying to %d prefixes" + +# Steam page +msgid "Steam" +msgstr "Steam" + +msgid "Paths" +msgstr "Paths" + +msgid "Steam directory" +msgstr "Steam directory" + +msgid "Choose folder" +msgstr "Choose folder" + +msgid "Launch options" +msgstr "Launch options" + +msgid "LaunchOptions template for protonhax" +msgstr "LaunchOptions template for protonhax" + +msgid "Template" +msgstr "Template" + +msgid "Original launch command is substituted" +msgstr "Original launch command is substituted" + +msgid "Exclusions" +msgstr "Exclusions" + +msgid "Regex patterns to hide apps" +msgstr "Regex patterns to hide apps" + +msgid "Add pattern" +msgstr "Add pattern" + +msgid "Remove" +msgstr "Remove" + +# CE page +msgid "Cheat Engine" +msgstr "Cheat Engine" + +msgid "Installation" +msgstr "Installation" + +msgid "Path to executable" +msgstr "Path to executable" + +msgid "Download" +msgstr "Download" + +msgid "Download Cheat Engine" +msgstr "Download Cheat Engine" + +msgid "Latest GitHub release" +msgstr "Latest GitHub release" + +msgid "Check for updates" +msgstr "Check for updates" + +msgid "Checking..." +msgstr "Checking..." + +msgid "CE is up to date" +msgstr "CE is up to date" + +msgid "Update available: %s" +msgstr "Update available: %s" + +msgid "Failed to check" +msgstr "Failed to check" + +msgid "Downloading..." +msgstr "Downloading..." + +msgid "CE downloaded successfully" +msgstr "CE downloaded successfully" + +msgid "Download failed" +msgstr "Download failed" + +msgid "Localization" +msgstr "Localization" + +msgid "Install Russian localization" +msgstr "Install Russian localization" + +msgid "Downloads from official Cheat Engine repository" +msgstr "Downloads from official Cheat Engine repository" + +msgid "Localization installed" +msgstr "Localization installed" + +msgid "Localization already installed" +msgstr "Localization already installed" + +msgid "CE interface language" +msgstr "CE interface language" + +msgid "Russian" +msgstr "Russian" + +# Main page +msgid "Search..." +msgstr "Search..." + +msgid "Games not found" +msgstr "Games not found" + +msgid "Check Steam path in settings" +msgstr "Check Steam path in settings" + +msgid "Refresh cover" +msgstr "Refresh cover" + +msgid "Launch game" +msgstr "Launch game" + +msgid "Launch Cheat Engine" +msgstr "Launch Cheat Engine" + +msgid "Launching %s..." +msgstr "Launching %s..." + +msgid "CE not found: %s" +msgstr "CE not found: %s" + +msgid "CE launched for %s" +msgstr "CE launched for %s" + +msgid "Failed to launch CE for %s" +msgstr "Failed to launch CE for %s" + +msgid "Close Steam to change settings" +msgstr "Close Steam to change settings" + +msgid "localconfig.vdf not found" +msgstr "localconfig.vdf not found" + +msgid "LaunchOptions set for %s" +msgstr "LaunchOptions set for %s" + +msgid "LaunchOptions removed for %s" +msgstr "LaunchOptions removed for %s" + +msgid "Error changing settings for %s" +msgstr "Error changing settings for %s" + +msgid "Enable for all games?" +msgstr "Enable for all games?" + +msgid "Enable" +msgstr "Enable" + +msgid "Disable for all games?" +msgstr "Disable for all games?" + +msgid "LaunchOptions will be removed." +msgstr "LaunchOptions will be removed." + +msgid "Disable" +msgstr "Disable" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Restarting Steam to apply..." +msgstr "Restarting Steam to apply..." + +msgid "Steam did not close" +msgstr "Steam did not close" + +msgid "LaunchOptions set for %d games" +msgstr "LaunchOptions set for %d games" + +msgid "LaunchOptions removed for %d games" +msgstr "LaunchOptions removed for %d games" + +# Window +msgid "games" +msgstr "games" + +msgid "Enable All" +msgstr "Enable All" + +msgid "Disable All" +msgstr "Disable All" + +msgid "Settings" +msgstr "Settings" + +msgid "About" +msgstr "About" + +# CE page additional +msgid "All files" +msgstr "All files" + +msgid "Cheat Engine setup and download" +msgstr "Cheat Engine setup and download" + +msgid "Cheat Engine installed" +msgstr "Cheat Engine installed" + +msgid "Cheat Engine interface language" +msgstr "Cheat Engine interface language" + +msgid "Debug" +msgstr "Debug" + +msgid "Downloads latest version from GitHub" +msgstr "Downloads latest version from GitHub" + +msgid "Cheat Engine launched" +msgstr "Cheat Engine launched" + +msgid "Cheat Engine not found" +msgstr "Cheat Engine not found" + +msgid "Cheat Engine required" +msgstr "Cheat Engine required" + +msgid "Check" +msgstr "Check" + +msgid "Choose file" +msgstr "Choose file" + +msgid "Click Check" +msgstr "Click Check" + +msgid "%d games" +msgstr "%d games" + +msgid "Downloading: %s" +msgstr "Downloading: %s" + +msgid "Error: %s" +msgstr "Error: %s" + +msgid "Executable" +msgstr "Executable" + +msgid "Executable files (*.exe)" +msgstr "Executable files (*.exe)" + +msgid "✓ External: %s" +msgstr "✓ External: %s" + +msgid "Extracted — specify path manually" +msgstr "Extracted — specify path manually" + +msgid "Extracting..." +msgstr "Extracting..." + +msgid "Extraction failed" +msgstr "Extraction failed" + +msgid "Failed to get info" +msgstr "Failed to get info" + +msgid "Failed to install localization" +msgstr "Failed to install localization" + +msgid "Install" +msgstr "Install" + +msgid "✓ Installed" +msgstr "✓ Installed" + +msgid "Install or update" +msgstr "Install or update" + +msgid "Interface" +msgstr "Interface" + +msgid "Latest version" +msgstr "Latest version" + +msgid "Launch" +msgstr "Launch" + +msgid "Launch CE via Wine (without game binding)" +msgstr "Launch CE via Wine (without game binding)" + +msgid "Required for attaching to Proton games" +msgstr "Required for attaching to Proton games" + +msgid "Requires CE restart" +msgstr "Requires CE restart" + +msgid "LaunchOptions %s for %s" +msgstr "LaunchOptions %s for %s" + +msgid "MB" +msgstr "MB" + +msgid "✗ Not found" +msgstr "✗ Not found" + +msgid "✗ Not installed" +msgstr "✗ Not installed" + +msgid "Not installed" +msgstr "Not installed" + +msgid "protonhax installed" +msgstr "protonhax installed" + +msgid "Quit" +msgstr "Quit" + +msgid "Refresh" +msgstr "Refresh" + +msgid "Reinstall" +msgstr "Reinstall" + +msgid "removed" +msgstr "removed" + +msgid "Russian localization" +msgstr "Russian localization" + +msgid "Select CE executable" +msgstr "Select CE executable" + +msgid "Select Steam directory" +msgstr "Select Steam directory" + +msgid "set" +msgstr "set" + +msgid "Status" +msgstr "Status" + +msgid "Steam did not exit" +msgstr "Steam did not exit" + +msgid "Test launch" +msgstr "Test launch" + +msgid "⟳ Update available" +msgstr "⟳ Update available" + +msgid "Update" +msgstr "Update" + +msgid "Wine not found — install Proton" +msgstr "Wine not found — install Proton" diff --git a/po/ru.po b/po/ru.po new file mode 100644 index 0000000..5d4ad10 --- /dev/null +++ b/po/ru.po @@ -0,0 +1,460 @@ +# Russian translation for Vual +# Copyright (C) 2026 Vual Authors +# This file is distributed under the same license as the Vual package. +# +msgid "" +msgstr "" +"Project-Id-Version: vual 0.1.0\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" +"Last-Translator: \n" +"Language-Team: Russian\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" + +# Window titles +msgid "Preferences" +msgstr "Настройки" + +msgid "About Vual" +msgstr "О Vual" + +# Appearance page +msgid "Appearance" +msgstr "Внешний вид" + +msgid "Theme" +msgstr "Тема" + +msgid "Color scheme" +msgstr "Цветовая схема" + +msgid "Light, dark, or system" +msgstr "Светлая, тёмная или системная" + +msgid "System" +msgstr "Системная" + +msgid "Light" +msgstr "Светлая" + +msgid "Dark" +msgstr "Тёмная" + +msgid "Language" +msgstr "Язык" + +msgid "Application interface language" +msgstr "Язык интерфейса приложения" + +msgid "Restart app to apply language" +msgstr "Перезапустите приложение для смены языка" + +msgid "Grid" +msgstr "Сетка" + +msgid "Game display settings" +msgstr "Настройки отображения игр" + +msgid "Tile size" +msgstr "Размер плиток" + +msgid "Cover size in library" +msgstr "Размер обложек в библиотеке" + +msgid "Small (120px)" +msgstr "Маленькие (120px)" + +msgid "Medium (150px)" +msgstr "Средние (150px)" + +msgid "Large (180px)" +msgstr "Большие (180px)" + +msgid "Sort" +msgstr "Сортировка" + +msgid "Default game order" +msgstr "Порядок игр по умолчанию" + +msgid "By name" +msgstr "По названию" + +msgid "By status" +msgstr "По статусу" + +# Wine theme +msgid "Wine Theme" +msgstr "Тема Wine" + +msgid "Color scheme for Cheat Engine and other Wine apps" +msgstr "Цветовая схема для Cheat Engine и других Wine-приложений" + +msgid "Applied to all Proton prefixes" +msgstr "Применяется ко всем Proton-префиксам" + +msgid "Reapply theme" +msgstr "Применить тему заново" + +msgid "Theme applied to %d prefixes" +msgstr "Тема применена к %d префиксам" + +msgid "Error applying to %d prefixes" +msgstr "Ошибка применения к %d префиксам" + +# Steam page +msgid "Steam" +msgstr "Steam" + +msgid "Paths" +msgstr "Пути" + +msgid "Steam directory" +msgstr "Директория Steam" + +msgid "Choose folder" +msgstr "Выбрать папку" + +msgid "Launch options" +msgstr "Параметры запуска" + +msgid "LaunchOptions template for protonhax" +msgstr "Шаблон LaunchOptions для protonhax" + +msgid "Template" +msgstr "Шаблон" + +msgid "Original launch command is substituted" +msgstr "Подставляется оригинальная команда запуска игры" + +msgid "Exclusions" +msgstr "Исключения" + +msgid "Regex patterns to hide apps" +msgstr "Regex-паттерны для скрытия приложений" + +msgid "Add pattern" +msgstr "Добавить паттерн" + +msgid "Remove" +msgstr "Удалить" + +# CE page +msgid "Cheat Engine" +msgstr "Cheat Engine" + +msgid "Installation" +msgstr "Установка" + +msgid "Path to executable" +msgstr "Путь к исполняемому файлу" + +msgid "Download" +msgstr "Скачать" + +msgid "Download Cheat Engine" +msgstr "Скачать Cheat Engine" + +msgid "Latest GitHub release" +msgstr "Последний релиз с GitHub" + +msgid "Check for updates" +msgstr "Проверить обновления" + +msgid "Checking..." +msgstr "Проверка…" + +msgid "CE is up to date" +msgstr "CE актуален" + +msgid "Update available: %s" +msgstr "Доступно обновление: %s" + +msgid "Failed to check" +msgstr "Не удалось проверить" + +msgid "Downloading..." +msgstr "Загрузка…" + +msgid "CE downloaded successfully" +msgstr "CE успешно загружен" + +msgid "Download failed" +msgstr "Ошибка загрузки" + +msgid "Localization" +msgstr "Локализация" + +msgid "Install Russian localization" +msgstr "Установить русскую локализацию" + +msgid "Downloads from official Cheat Engine repository" +msgstr "Загружает с официального репозитория Cheat Engine" + +msgid "Localization installed" +msgstr "Локализация установлена" + +msgid "Localization already installed" +msgstr "Локализация уже установлена" + +msgid "CE interface language" +msgstr "Язык интерфейса CE" + +msgid "Russian" +msgstr "Русский" + +# Main page +msgid "Search..." +msgstr "Поиск…" + +msgid "Games not found" +msgstr "Игры не найдены" + +msgid "Check Steam path in settings" +msgstr "Проверьте путь к Steam в настройках" + +msgid "Refresh cover" +msgstr "Обновить обложку" + +msgid "Launch game" +msgstr "Запустить игру" + +msgid "Launch Cheat Engine" +msgstr "Запустить Cheat Engine" + +msgid "Launching %s..." +msgstr "Запуск %s…" + +msgid "CE not found: %s" +msgstr "CE не найден: %s" + +msgid "CE launched for %s" +msgstr "CE запущен для %s" + +msgid "Failed to launch CE for %s" +msgstr "Не удалось запустить CE для %s" + +msgid "Close Steam to change settings" +msgstr "Закройте Steam для изменения настроек" + +msgid "localconfig.vdf not found" +msgstr "localconfig.vdf не найден" + +msgid "LaunchOptions set for %s" +msgstr "LaunchOptions установлены для %s" + +msgid "LaunchOptions removed for %s" +msgstr "LaunchOptions удалены для %s" + +msgid "Error changing settings for %s" +msgstr "Ошибка при изменении настроек для %s" + +msgid "Enable for all games?" +msgstr "Включить для всех игр?" + +msgid "Enable" +msgstr "Включить" + +msgid "Disable for all games?" +msgstr "Отключить для всех игр?" + +msgid "LaunchOptions will be removed." +msgstr "LaunchOptions будут удалены." + +msgid "Disable" +msgstr "Отключить" + +msgid "Cancel" +msgstr "Отмена" + +msgid "Restarting Steam to apply..." +msgstr "Перезапуск Steam для применения…" + +msgid "Steam did not close" +msgstr "Steam не завершился" + +msgid "LaunchOptions set for %d games" +msgstr "LaunchOptions установлены для %d игр" + +msgid "LaunchOptions removed for %d games" +msgstr "LaunchOptions удалены для %d игр" + +# Window +msgid "games" +msgstr "игр" + +msgid "Enable All" +msgstr "Включить всё" + +msgid "Disable All" +msgstr "Отключить всё" + +msgid "Settings" +msgstr "Настройки" + +msgid "About" +msgstr "О программе" + +# CE page additional +msgid "Actions" +msgstr "Действия" + +msgid "All files" +msgstr "Все файлы" + +msgid "Cheat Engine setup and download" +msgstr "Настройка и скачивание Cheat Engine" + +msgid "Debug" +msgstr "Отладка" + +msgid "Cheat Engine installed" +msgstr "Cheat Engine установлен" + +msgid "Cheat Engine interface language" +msgstr "Язык интерфейса Cheat Engine" + +msgid "Downloads latest version from GitHub" +msgstr "Скачивает последнюю версию с GitHub" + +msgid "Interface" +msgstr "Интерфейс" + +msgid "Cheat Engine launched" +msgstr "Cheat Engine запущен" + +msgid "Cheat Engine not found" +msgstr "Cheat Engine не найден" + +msgid "Cheat Engine required" +msgstr "Требуется Cheat Engine" + +msgid "Check" +msgstr "Проверить" + +msgid "Choose file" +msgstr "Выбрать файл" + +msgid "Click Check" +msgstr "Нажмите «Проверить»" + +msgid "%d games" +msgstr "%d игр" + +msgid "Downloading: %s" +msgstr "Скачивание: %s" + +msgid "Error: %s" +msgstr "Ошибка: %s" + +msgid "Executable" +msgstr "Исполняемый файл" + +msgid "Executable files (*.exe)" +msgstr "Исполняемые файлы (*.exe)" + +msgid "✓ External: %s" +msgstr "✓ Внешний: %s" + +msgid "Extracted — specify path manually" +msgstr "Распаковано — укажите путь вручную" + +msgid "Extracting..." +msgstr "Распаковка…" + +msgid "Extraction failed" +msgstr "Распаковка не удалась" + +msgid "Failed to get info" +msgstr "Не удалось получить информацию" + +msgid "Failed to install localization" +msgstr "Не удалось установить локализацию" + +msgid "Install" +msgstr "Установить" + +msgid "✓ Installed" +msgstr "✓ Установлен" + +msgid "Install or update" +msgstr "Установить или обновить" + +msgid "Latest version" +msgstr "Последняя версия" + +msgid "Launch" +msgstr "Запустить" + +msgid "Launch CE via Wine (without game binding)" +msgstr "Запустить CE через Wine (без привязки к игре)" + +msgid "LaunchOptions %s for %s" +msgstr "LaunchOptions %s для %s" + +msgid "MB" +msgstr "МБ" + +msgid "✗ Not found" +msgstr "✗ Не найден" + +msgid "✗ Not installed" +msgstr "✗ Не установлен" + +msgid "Not installed" +msgstr "Не установлена" + +msgid "protonhax installed" +msgstr "protonhax установлен" + +msgid "Quit" +msgstr "Выход" + +msgid "Refresh" +msgstr "Обновить" + +msgid "Reinstall" +msgstr "Переустановить" + +msgid "removed" +msgstr "удалены" + +msgid "Russian localization" +msgstr "Русская локализация" + +msgid "Select CE executable" +msgstr "Выберите исполняемый файл CE" + +msgid "Select Steam directory" +msgstr "Выберите директорию Steam" + +msgid "set" +msgstr "установлены" + +msgid "Status" +msgstr "Статус" + +msgid "Required for attaching to Proton games" +msgstr "Требуется для подключения к играм Proton" + +msgid "Requires CE restart" +msgstr "Требует перезапуска CE" + +msgid "Steam did not exit" +msgstr "Steam не завершился" + +msgid "Test launch" +msgstr "Тест запуска" + +msgid "⟳ Update available" +msgstr "⟳ Доступно обновление" + +msgid "Update" +msgstr "Обновить" + +msgid "Wine not found — install Proton" +msgstr "Wine не найден — установите Proton" diff --git a/po/vual.pot b/po/vual.pot new file mode 100644 index 0000000..515bc4d --- /dev/null +++ b/po/vual.pot @@ -0,0 +1,298 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2026 Vual Authors +# This file is distributed under the same license as the Vual package. +# FIRST AUTHOR , 2026. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: vual 0.1.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" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +# Window titles +msgid "Preferences" +msgstr "" + +msgid "About Vual" +msgstr "" + +# Appearance page +msgid "Appearance" +msgstr "" + +msgid "Theme" +msgstr "" + +msgid "Color scheme" +msgstr "" + +msgid "Light, dark, or system" +msgstr "" + +msgid "System" +msgstr "" + +msgid "Light" +msgstr "" + +msgid "Dark" +msgstr "" + +msgid "Grid" +msgstr "" + +msgid "Game display settings" +msgstr "" + +msgid "Tile size" +msgstr "" + +msgid "Cover size in library" +msgstr "" + +msgid "Small (120px)" +msgstr "" + +msgid "Medium (150px)" +msgstr "" + +msgid "Large (180px)" +msgstr "" + +msgid "Sort" +msgstr "" + +msgid "Default game order" +msgstr "" + +msgid "By name" +msgstr "" + +msgid "By status" +msgstr "" + +# Wine theme +msgid "Wine Theme" +msgstr "" + +msgid "Color scheme for Cheat Engine and other Wine apps" +msgstr "" + +msgid "Color scheme" +msgstr "" + +msgid "Applied to all Proton prefixes" +msgstr "" + +msgid "Reapply theme" +msgstr "" + +msgid "Theme applied to %d prefixes" +msgstr "" + +msgid "Error applying to %d prefixes" +msgstr "" + +# Steam page +msgid "Steam" +msgstr "" + +msgid "Paths" +msgstr "" + +msgid "Steam directory" +msgstr "" + +msgid "Choose folder" +msgstr "" + +msgid "Launch options" +msgstr "" + +msgid "LaunchOptions template for protonhax" +msgstr "" + +msgid "Template" +msgstr "" + +msgid "Original launch command is substituted" +msgstr "" + +msgid "Exclusions" +msgstr "" + +msgid "Regex patterns to hide apps" +msgstr "" + +msgid "Add pattern" +msgstr "" + +msgid "Remove" +msgstr "" + +# CE page +msgid "Cheat Engine" +msgstr "" + +msgid "Installation" +msgstr "" + +msgid "Path to executable" +msgstr "" + +msgid "Download" +msgstr "" + +msgid "Download Cheat Engine" +msgstr "" + +msgid "Latest GitHub release" +msgstr "" + +msgid "Check for updates" +msgstr "" + +msgid "Checking..." +msgstr "" + +msgid "CE is up to date" +msgstr "" + +msgid "Update available: %s" +msgstr "" + +msgid "Failed to check" +msgstr "" + +msgid "Downloading..." +msgstr "" + +msgid "CE downloaded successfully" +msgstr "" + +msgid "Download failed" +msgstr "" + +msgid "Localization" +msgstr "" + +msgid "Install Russian localization" +msgstr "" + +msgid "Downloads from official Cheat Engine repository" +msgstr "" + +msgid "Localization installed" +msgstr "" + +msgid "Localization already installed" +msgstr "" + +msgid "Language" +msgstr "" + +msgid "CE interface language" +msgstr "" + +msgid "Russian" +msgstr "" + +# Main page +msgid "Search..." +msgstr "" + +msgid "Games not found" +msgstr "" + +msgid "Check Steam path in settings" +msgstr "" + +msgid "Refresh cover" +msgstr "" + +msgid "Launch game" +msgstr "" + +msgid "Launch Cheat Engine" +msgstr "" + +msgid "Launching %s..." +msgstr "" + +msgid "CE not found: %s" +msgstr "" + +msgid "CE launched for %s" +msgstr "" + +msgid "Failed to launch CE for %s" +msgstr "" + +msgid "Close Steam to change settings" +msgstr "" + +msgid "localconfig.vdf not found" +msgstr "" + +msgid "LaunchOptions set for %s" +msgstr "" + +msgid "LaunchOptions removed for %s" +msgstr "" + +msgid "Error changing settings for %s" +msgstr "" + +msgid "Enable for all games?" +msgstr "" + +msgid "Enable" +msgstr "" + +msgid "Disable for all games?" +msgstr "" + +msgid "LaunchOptions will be removed." +msgstr "" + +msgid "Disable" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "Restarting Steam to apply..." +msgstr "" + +msgid "Steam did not close" +msgstr "" + +msgid "LaunchOptions set for %d games" +msgstr "" + +msgid "LaunchOptions removed for %d games" +msgstr "" + +# Window +msgid "games" +msgstr "" + +msgid "Enable All" +msgstr "" + +msgid "Disable All" +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "About" +msgstr "" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e0c597b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "vual" +version = "0.1.0" +description = "Launch Cheat Engine for Steam games via Proton" +readme = "README.md" +license = "GPL-3.0-or-later" +requires-python = ">=3.11" +authors = [{ name = "Cheviiot" }] +keywords = ["cheatengine", "steam", "proton", "wine", "games", "gtk4", "libadwaita"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: X11 Applications :: GTK", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Games/Entertainment", +] +dependencies = ["requests>=2.31.0"] + +[project.urls] +Homepage = "https://github.com/Cheviiot/vual" +Repository = "https://github.com/Cheviiot/vual" +Issues = "https://github.com/Cheviiot/vual/issues" + +[project.gui-scripts] +vual = "vual.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/vual"] + +[tool.uv] +managed = true diff --git a/src/vual/__init__.py b/src/vual/__init__.py new file mode 100644 index 0000000..3d59679 --- /dev/null +++ b/src/vual/__init__.py @@ -0,0 +1,19 @@ +"""Vual — launch Cheat Engine for Steam games via Proton.""" + +from pathlib import Path + +__version__ = "0.1.0" +APP_ID = "io.github.vual" +APP_NAME = "Vual" + +# Paths: try development first, then fall back to installed locations +_PKG_DIR = Path(__file__).parent +_DATA_DIR = _PKG_DIR.parent.parent / "data" + +# Icon +_DEV_ICON = _DATA_DIR / "Vual.png" +ICON_PATH = _DEV_ICON if _DEV_ICON.exists() else None + +# CSS (in package data) +_PKG_CSS = _PKG_DIR / "data" / "style.css" +CSS_PATH = _PKG_CSS if _PKG_CSS.exists() else None diff --git a/src/vual/__main__.py b/src/vual/__main__.py new file mode 100644 index 0000000..93b04bc --- /dev/null +++ b/src/vual/__main__.py @@ -0,0 +1,57 @@ +"""Entry point for the Vual GUI application.""" + +import os +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 + return + except ImportError: + pass + + candidates = [ + "/usr/lib64/python3/site-packages", + "/usr/lib/python3/site-packages", + "/usr/lib/python3/dist-packages", + f"/usr/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages", + ] + for path in candidates: + if os.path.isdir(os.path.join(path, "gi")): + sys.path.insert(0, path) + return + + print( + "Error: PyGObject (gi) not found.\n" + "Install it with your package manager:\n" + " ALT Linux: sudo apt-get install python3-module-pygobject3\n" + " Fedora: sudo dnf install python3-gobject gtk4 libadwaita\n" + " Ubuntu: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1\n" + " Arch: sudo pacman -S python-gobject gtk4 libadwaita", + file=sys.stderr, + ) + sys.exit(1) + + +def main(): + _ensure_gi_available() + + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + + from vual.app import VualApp + + app = VualApp() + app.run(sys.argv) + + +if __name__ == "__main__": + main() + + +if __name__ == "__main__": + main() diff --git a/src/vual/app.py b/src/vual/app.py new file mode 100644 index 0000000..541e6aa --- /dev/null +++ b/src/vual/app.py @@ -0,0 +1,99 @@ +"""Adw.Application for Vual.""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402 + +from vual import APP_ID, APP_NAME, CSS_PATH, ICON_PATH, __version__ # noqa: E402 +from vual.config import Config # noqa: E402 +from vual.i18n import init as init_i18n # noqa: E402 +from vual.window import VualWindow # noqa: E402 + + +class VualApp(Adw.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + self.config = Config.load() + init_i18n(self.config.app_language) # Initialize translations with saved language + + # ── Activate ───────────────────────────────────────────────── + + def do_activate(self) -> None: + # Apply color scheme + style_manager = Adw.StyleManager.get_default() + scheme_map = { + "light": Adw.ColorScheme.FORCE_LIGHT, + "dark": Adw.ColorScheme.FORCE_DARK, + "system": Adw.ColorScheme.DEFAULT, + } + style_manager.set_color_scheme(scheme_map.get(self.config.color_scheme, Adw.ColorScheme.DEFAULT)) + + win = self.props.active_window + if not win: + win = VualWindow(application=self, config=self.config) + win.set_default_size(self.config.window_width, self.config.window_height) + win.present() + + # ── Startup: register actions ──────────────────────────────── + + def do_startup(self) -> None: + Adw.Application.do_startup(self) + display = Gdk.Display.get_default() + + # Load application CSS + if CSS_PATH and display: + css_provider = Gtk.CssProvider() + css_provider.load_from_path(str(CSS_PATH)) + Gtk.StyleContext.add_provider_for_display( + display, + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + # Add custom icon path to theme (for development) + if ICON_PATH and display: + Gtk.IconTheme.get_for_display(display).add_search_path(str(ICON_PATH.parent)) + + about_action = Gio.SimpleAction.new("about", None) + about_action.connect("activate", self._on_about) + self.add_action(about_action) + + prefs_action = Gio.SimpleAction.new("preferences", None) + prefs_action.connect("activate", self._on_preferences) + self.add_action(prefs_action) + + quit_action = Gio.SimpleAction.new("quit", None) + quit_action.connect("activate", lambda *_: self.quit()) + self.add_action(quit_action) + self.set_accels_for_action("app.quit", ["q"]) + + # ── About dialog ───────────────────────────────────────────── + + def _on_about(self, _action: Gio.SimpleAction, _param: None) -> None: + # Use dev icon name if in dev mode, otherwise use app_id (installed icon) + icon_name = "Vual" if ICON_PATH else APP_ID + about = Adw.AboutDialog( + application_name=APP_NAME, + application_icon=icon_name, + version=__version__, + developer_name="Cheviiot", + website="https://github.com/Cheviiot/vual", + issue_url="https://github.com/Cheviiot/vual/issues", + developers=["Cheviiot"], + copyright="© 2026 Cheviiot", + license_type=Gtk.License.GPL_3_0, + ) + about.present(self.props.active_window) + + # ── Preferences ────────────────────────────────────────────── + + def _on_preferences(self, _action: Gio.SimpleAction, _param: None) -> None: + from vual.ui.preferences import PreferencesWindow + + win = PreferencesWindow(config=self.config, transient_for=self.props.active_window) + win.present() diff --git a/src/vual/cheatengine.py b/src/vual/cheatengine.py new file mode 100644 index 0000000..e5f4f20 --- /dev/null +++ b/src/vual/cheatengine.py @@ -0,0 +1,463 @@ +"""Cheat Engine management: download, extraction, and version detection. + +Provides functionality for: +- Fetching latest CE release info from cheatengine.org +- Downloading the installer with progress reporting +- Extracting using Wine from Proton (silent install) +- Detecting CE version from installed files +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import requests + +if TYPE_CHECKING: + from collections.abc import Callable + +# Official Cheat Engine download page +CE_DOWNLOADS_URL = "https://cheatengine.org/downloads.php" + +# Known Proton versions to search for Wine binary +_PROTON_DIRS = [ + "Proton - Experimental", + "Proton 9.0", + "Proton 8.0", + "Proton 7.0", +] + +# CE executable names in order of preference +_CE_EXECUTABLES = [ + "cheatengine-x86_64.exe", + "cheatengine-i386.exe", + "Cheat Engine.exe", +] + + +# ════════════════════════════════════════════════════════════════ +# Release Info +# ════════════════════════════════════════════════════════════════ + + +def get_latest_release() -> dict[str, str | int | None] | None: + """Fetch latest Cheat Engine release info from official website. + + Scrapes cheatengine.org/downloads.php for the Windows installer link. + + Returns: + Dict with keys: version, url, name, size (or None on error). + """ + try: + resp = requests.get(CE_DOWNLOADS_URL, timeout=15) + resp.raise_for_status() + text = resp.text + + # Extract version (e.g. "Cheat Engine 7.6") + ver_m = re.search(r"Cheat Engine\s+([\d.]+)", text) + version = ver_m.group(1) if ver_m else "unknown" + + # Find the first cloudfront .exe link (Windows installer) + exe_m = re.search(r'href="(https://[^"]*cloudfront[^"]*\.exe)"', text, re.I) + if not exe_m: + return {"version": version, "url": None, "name": None, "size": 0} + + url = exe_m.group(1) + name = f"CheatEngine{version.replace('.', '')}.exe" + + # Get file size via HEAD request + size = 0 + try: + head = requests.head(url, allow_redirects=True, timeout=10) + size = int(head.headers.get("content-length", 0)) + except requests.RequestException: + pass + + return {"version": version, "url": url, "name": name, "size": size} + except (requests.RequestException, ValueError): + return None + + +# ════════════════════════════════════════════════════════════════ +# Download +# ════════════════════════════════════════════════════════════════ + + +def download_file( + url: str, + dest: Path, + progress_cb: Callable[[float], None] | None = None, +) -> bool: + """Download a file with optional progress reporting. + + Args: + url: URL to download. + dest: Destination path for the file. + progress_cb: Optional callback receiving progress (0.0-1.0). + + Returns: + True on success, False on error. + """ + try: + resp = requests.get(url, stream=True, timeout=120) + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, "wb") as f: + for chunk in resp.iter_content(chunk_size=65536): + f.write(chunk) + downloaded += len(chunk) + if progress_cb and total > 0: + progress_cb(downloaded / total) + return True + except requests.RequestException: + return False + + +# ════════════════════════════════════════════════════════════════ +# Proton Wine Discovery +# ════════════════════════════════════════════════════════════════ + + +def find_proton_wine(steam_path: str = "~/.local/share/Steam") -> Path | None: + """Find a Wine binary bundled with a Proton installation. + + Searches common Proton versions first, then falls back to any + Proton directory in steamapps/common. + + Args: + steam_path: Path to Steam installation (can use ~). + + Returns: + Path to Wine binary, or None if not found. + """ + steamapps = Path(steam_path).expanduser() / "steamapps" / "common" + if not steamapps.is_dir(): + return None + for name in _PROTON_DIRS: + wine = steamapps / name / "files" / "bin" / "wine" + if wine.is_file(): + return wine + # Fallback: search any Proton directory + for d in sorted(steamapps.iterdir(), reverse=True): + if d.name.lower().startswith("proton"): + wine = d / "files" / "bin" / "wine" + if wine.is_file(): + return wine + return None + + +# ════════════════════════════════════════════════════════════════ +# Extraction +# ════════════════════════════════════════════════════════════════ + + +def can_extract(steam_path: str = "~/.local/share/Steam") -> str | None: + """Check if extraction is possible. + + Args: + steam_path: Path to Steam installation. + + Returns: + Name of extraction method ("wine") or None if unavailable. + """ + if find_proton_wine(steam_path): + return "wine" + return None + + +def extract_installer( + installer: Path, + dest_dir: Path, + steam_path: str = "~/.local/share/Steam", +) -> bool: + """Extract Cheat Engine installer into destination directory. + + Uses Wine from Proton to run the silent installer. The CE installer + is wrapped by zbShield which doesn't exit cleanly in silent mode, + so we poll for completion and kill the process. + + Args: + installer: Path to CE installer executable. + dest_dir: Destination directory for extracted files. + steam_path: Path to Steam installation. + + Returns: + True on success, False on error. + """ + wine = find_proton_wine(steam_path) + if not wine: + return False + return _extract_via_wine(wine, installer, dest_dir) + + +def _extract_via_wine(wine: Path, installer: Path, dest_dir: Path) -> bool: + """Run CE installer silently via Proton Wine and copy results.""" + cache_dir = Path.home() / ".cache" / "vual" + cache_dir.mkdir(parents=True, exist_ok=True) + wineserver = wine.parent / "wineserver" + + with tempfile.TemporaryDirectory(dir=cache_dir, prefix="ce_install_") as tmpdir: + prefix = Path(tmpdir) / "prefix" + prefix.mkdir() + + env = dict(os.environ) + env.update({ + "WINEPREFIX": str(prefix), + "WINEDLLOVERRIDES": "mshtml=d", + "WINEDEBUG": "-all", + "PATH": str(wine.parent) + ":" + env.get("PATH", ""), + }) + + # Initialize Wine prefix + try: + subprocess.run( + [str(wine), "wineboot", "--init"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=120, + env=env, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired): + pass + + # Launch installer as a background process — zbShield wrapper + # never exits cleanly in silent mode, so we poll for results. + proc = subprocess.Popen( + [ + str(wine), + str(installer), + "/VERYSILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/SP-", + "/NORUN", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + + ce_src = prefix / "drive_c" / "Program Files" / "Cheat Engine" + marker = ce_src / "cheatengine-x86_64.exe" + completion = ce_src / "unins000.exe" + + # Poll until both the 64-bit CE exe AND the uninstaller appear + # and the total file count stabilises for 3 consecutive seconds. + prev_count = 0 + stable = 0 + try: + for _ in range(180): # max 3 minutes + time.sleep(1) + if marker.is_file() and completion.is_file(): + cur = sum(1 for _ in ce_src.rglob("*")) + if cur == prev_count and cur > 50: + stable += 1 + if stable >= 3: + break + else: + stable = 0 + prev_count = cur + else: + # Timeout — use whatever we have if any + if not ce_src.is_dir(): + return False + finally: + proc.kill() + try: + subprocess.run( + [str(wineserver), "-k"], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + time.sleep(1) + + if not ce_src.is_dir(): + return False + + # Move installed files to dest_dir + dest_dir.mkdir(parents=True, exist_ok=True) + for item in ce_src.iterdir(): + target = dest_dir / item.name + if target.exists(): + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + shutil.move(str(item), str(target)) + + return True + + +# ════════════════════════════════════════════════════════════════ +# Executable Detection +# ════════════════════════════════════════════════════════════════ + + +def find_executable(search_dir: Path) -> Path | None: + """Find the main CE executable in a directory. + + Searches for known CE executable names in order of preference. + + Args: + search_dir: Directory to search recursively. + + Returns: + Path to CE executable, or None if not found. + """ + # Try known names first + for name in _CE_EXECUTABLES: + for hit in search_dir.rglob(name): + return hit + # Fallback: any cheatengine*.exe + for hit in search_dir.rglob("cheatengine*.exe"): + return hit + return None + + +def detect_version(ce_path: Path) -> str | None: + """Detect CE version from file names or paths. + + Args: + ce_path: Path to CE installation. + + Returns: + Version string (e.g., "7.6") or None if not detected. + """ + # Check installer filename in cache (e.g. CheatEngine76.exe → 7.6) + cache = Path.home() / ".cache" / "vual" + if cache.is_dir(): + for f in cache.iterdir(): + if f.suffix == ".exe" and "cheatengine" in f.name.lower(): + m = re.search(r"(\d)(\d+)", f.stem) + if m: + return f"{m.group(1)}.{m.group(2)}" + + # Check parent directory name + for part in ce_path.parts: + m = re.search(r"[Cc]heat.?[Ee]ngine\s*(\d+\.?\d*)", part) + if m: + return m.group(1) + return None + + +# ════════════════════════════════════════════════════════════════ +# Localization +# ════════════════════════════════════════════════════════════════ + +# Russian localization from official CE repo +_LOCALIZATION_API = ( + "https://api.github.com/repos/cheat-engine/cheat-engine/contents/" + "Cheat%20Engine/bin/languages/ru_RU" +) +_LOCALIZATION_RAW = ( + "https://raw.githubusercontent.com/cheat-engine/cheat-engine/master/" + "Cheat%20Engine/bin/languages/ru_RU/" +) +_LOCALIZATION_DIR = "ru_RU" + + +def get_languages_dir(ce_path: Path) -> Path | None: + """Get the languages directory for CE installation. + + Args: + ce_path: Path to CE executable. + + Returns: + Path to languages directory, or None if invalid. + """ + if ce_path.is_file(): + return ce_path.parent / "languages" + return None + + +def is_localization_installed(ce_path: Path) -> bool: + """Check if Russian localization is installed. + + Args: + ce_path: Path to CE executable. + + Returns: + True if ru_RU directory exists with .po files. + """ + lang_dir = get_languages_dir(ce_path) + if not lang_dir: + return False + ru_dir = lang_dir / _LOCALIZATION_DIR + if not ru_dir.is_dir(): + return False + # Check for at least one .po file + return any(ru_dir.glob("*.po")) + + +def _get_localization_files() -> list[str]: + """Fetch list of files in ru_RU localization directory from GitHub API.""" + try: + resp = requests.get(_LOCALIZATION_API, timeout=15) + resp.raise_for_status() + data = resp.json() + if isinstance(data, list): + return [item["name"] for item in data if item.get("type") == "file"] + except (requests.RequestException, ValueError, KeyError): + pass + return [] + + +def install_localization( + ce_path: Path, + progress_cb: Callable[[float], None] | None = None, +) -> bool: + """Download and install Russian localization for Cheat Engine. + + Downloads ru_RU localization files from official CE repository + and places them in the languages/ru_RU directory. + + Args: + ce_path: Path to CE executable. + progress_cb: Optional callback receiving progress (0.0-1.0). + + Returns: + True on success, False on error. + """ + lang_dir = get_languages_dir(ce_path) + if not lang_dir: + return False + + ru_dir = lang_dir / _LOCALIZATION_DIR + ru_dir.mkdir(parents=True, exist_ok=True) + + files = _get_localization_files() + if not files: + return False + + total = len(files) + downloaded = 0 + + for filename in files: + url = _LOCALIZATION_RAW + filename + dest = ru_dir / filename + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + dest.write_bytes(resp.content) + downloaded += 1 + if progress_cb: + progress_cb(downloaded / total) + except requests.RequestException: + # Continue with other files + pass + + # Success if at least the main .po file downloaded + return (ru_dir / "cheatengine-x86_64.po").is_file() diff --git a/src/vual/config.py b/src/vual/config.py new file mode 100644 index 0000000..f43825b --- /dev/null +++ b/src/vual/config.py @@ -0,0 +1,131 @@ +"""Configuration management for Vual. + +Stores settings in ~/.config/vual/config.json +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path + +_DEFAULT_PROTONHAX = str(Path.home() / ".local" / "share" / "vual" / "bin" / "protonhax") +_DEFAULT_TEMPLATE = f"{_DEFAULT_PROTONHAX} init %COMMAND%" +_DEFAULT_EXCLUDED = ["^Proton", "^Steam Linux Runtime"] + +# Config file location +CONFIG_PATH = Path.home() / ".config" / "vual" / "config.json" + + +# Tile size presets: small=120, medium=150, large=180 +TILE_SIZES = {"small": 120, "medium": 150, "large": 180} + + +@dataclass +class Config: + """Application configuration. + + Attributes: + ce_executable: Path to Cheat Engine executable. + steam_path: Path to Steam installation directory. + lookup_enabled: Whether to look up game info. + launch_options_template: Template for Steam launch options. + excluded_app_patterns: Regex patterns to exclude apps. + window_width: Main window width. + window_height: Main window height. + color_scheme: Color scheme ("system", "light", "dark"). + tile_size: Tile size preset ("small", "medium", "large"). + sort_by: Sort order ("name", "status"). + ce_language: CE language ("system", "ru_RU"). + wine_theme: Wine color theme ("system", "dark", "light"). + app_language: Application UI language ("system", "en", "ru"). + """ + + ce_executable: str = "~/.local/share/vual/cheatengine/cheatengine-x86_64.exe" + steam_path: str = "~/.local/share/Steam" + lookup_enabled: bool = True + launch_options_template: str = field(default_factory=lambda: _DEFAULT_TEMPLATE) + excluded_app_patterns: list[str] = field(default_factory=lambda: _DEFAULT_EXCLUDED.copy()) + window_width: int = 1000 + window_height: int = 700 + color_scheme: str = "system" + tile_size: str = "medium" + sort_by: str = "name" + ce_language: str = "system" + wine_theme: str = "system" + app_language: str = "system" + + # ════════════════════════════════════════════════════════════════ + # Load / Save + # ════════════════════════════════════════════════════════════════ + + @classmethod + def load(cls) -> "Config": + """Load configuration from JSON file. + + Returns: + Config instance with loaded or default values. + """ + if CONFIG_PATH.exists(): + try: + data = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + return cls( + ce_executable=data.get("ce_executable", cls.ce_executable), + steam_path=data.get("steam_path", cls.steam_path), + lookup_enabled=data.get("lookup_enabled", cls.lookup_enabled), + launch_options_template=data.get("launch_options_template", _DEFAULT_TEMPLATE), + excluded_app_patterns=data.get("excluded_app_patterns", _DEFAULT_EXCLUDED.copy()), + window_width=data.get("window_width", cls.window_width), + window_height=data.get("window_height", cls.window_height), + color_scheme=data.get("color_scheme", cls.color_scheme), + tile_size=data.get("tile_size", cls.tile_size), + sort_by=data.get("sort_by", cls.sort_by), + 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), + ) + except (json.JSONDecodeError, OSError): + pass + return cls() + + def save(self) -> Path: + """Save configuration to JSON file. + + Returns: + Path to the saved config file. + """ + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + data = { + "ce_executable": self.ce_executable, + "steam_path": self.steam_path, + "lookup_enabled": self.lookup_enabled, + "launch_options_template": self.launch_options_template, + "excluded_app_patterns": self.excluded_app_patterns, + "window_width": self.window_width, + "window_height": self.window_height, + "color_scheme": self.color_scheme, + "tile_size": self.tile_size, + "sort_by": self.sort_by, + "ce_language": self.ce_language, + "wine_theme": self.wine_theme, + "app_language": self.app_language, + } + CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return CONFIG_PATH + + # ════════════════════════════════════════════════════════════════ + # Derived Paths + # ════════════════════════════════════════════════════════════════ + + @property + def steamapps_path(self) -> Path: + """Path to Steam's steamapps directory.""" + return Path(self.steam_path).expanduser() / "steamapps" + + @property + def ce_executable_path(self) -> Path: + """Expanded path to CE executable.""" + return Path(self.ce_executable).expanduser() + + @property + def ce_exists(self) -> bool: + """Check if CE executable exists.""" + return self.ce_executable_path.is_file() diff --git a/src/vual/data/style.css b/src/vual/data/style.css new file mode 100644 index 0000000..707d978 --- /dev/null +++ b/src/vual/data/style.css @@ -0,0 +1,195 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + Vual — Unified Size System + + Base unit: 4px + All sizes are multiples of 4px for consistency + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ═══════════════════════════════════════════════════════════════════════════ + HEADER BAR + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Header buttons: 32x32 (8 units) */ +.header-btn { + min-width: 32px; + min-height: 32px; + padding: 0; + margin: 0; + border-radius: 6px; +} + +.header-btn:hover { + background: alpha(@accent_bg_color, 0.12); +} + +.header-btn:active { + background: alpha(@accent_bg_color, 0.2); +} + +/* Games counter */ +.games-counter { + font-size: 0.85em; + font-weight: 500; + opacity: 0.6; + margin-left: 8px; +} + +/* Search entry */ +searchentry { + min-width: 220px; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + GRID LAYOUT + ═══════════════════════════════════════════════════════════════════════════ */ + +.vual-grid { + /* Padding handled in Python via set_margin_* */ +} + +flowboxchild { + padding: 0; + margin: 0; + background: transparent; + outline: none; + border: none; + box-shadow: none; +} + +flowboxchild:focus, +flowboxchild:hover, +flowboxchild:selected { + background: transparent; + outline: none; + box-shadow: none; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + TILE + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Tile container: 12px radius */ +.vual-tile { + border-radius: 12px; + background: @card_bg_color; + box-shadow: 0 1px 2px alpha(black, 0.08); +} + +.vual-tile:hover { + box-shadow: 0 2px 8px alpha(black, 0.15); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + TILE INFO OVERLAY (bottom) + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Info panel: gradient background */ +.vual-tile-info { + padding: 24px 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; +} + +.vual-tile-info label { + color: white; +} + +.vual-tile-info .heading { + font-weight: 600; + font-size: 0.85em; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + TILE BUTTONS (play, CE) + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Tile buttons: 28x28 (7 units) */ +.tile-btn, +.vual-tile-info button { + min-width: 28px; + min-height: 28px; + padding: 0; + margin: 0; + color: white; + background: alpha(white, 0.12); + border-radius: 6px; + border: none; +} + +.tile-btn:hover, +.vual-tile-info button:hover { + background: alpha(white, 0.24); +} + +.tile-btn:active, +.vual-tile-info button:active { + background: alpha(white, 0.32); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + TILE SWITCH + ═══════════════════════════════════════════════════════════════════════════ */ + +.vual-tile-info switch { + background: alpha(white, 0.15); + border: none; +} + +.vual-tile-info switch:checked { + background: @accent_bg_color; +} + +.vual-tile-info switch slider { + background: white; + min-width: 16px; + min-height: 16px; + border-radius: 8px; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + RELOAD BUTTON (top-left corner) + ═══════════════════════════════════════════════════════════════════════════ */ + +/* 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); + color: white; + opacity: 0; + border: none; +} + +.vual-tile:hover .vual-reload-btn { + opacity: 1; +} + +.vual-reload-btn:hover { + background: @accent_bg_color; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + RUNNING BADGE (top-right corner) + ═══════════════════════════════════════════════════════════════════════════ */ + +/* Badge: 20px total (14px icon + 3px padding each side) */ +.vual-badge { + background: @success_bg_color; + border-radius: 50%; + padding: 3px; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + SKELETON (loading placeholder) + ═══════════════════════════════════════════════════════════════════════════ */ + +.skeleton { + background: alpha(@card_shade_color, 0.2); + border-radius: 12px; +} diff --git a/src/vual/i18n.py b/src/vual/i18n.py new file mode 100644 index 0000000..c21a417 --- /dev/null +++ b/src/vual/i18n.py @@ -0,0 +1,112 @@ +"""Internationalization support for Vual. + +Provides gettext-based translation functions. +""" + +from __future__ import annotations + +import gettext +import locale +import os +from pathlib import Path + +# Application domain +DOMAIN = "vual" + +# Locale directories to search (in order) +_PKG_DIR = Path(__file__).parent +_LOCALE_DIRS = [ + _PKG_DIR / "locale", # In-package (installed/compiled) + Path("/usr/share/locale"), # System-wide + Path("/usr/local/share/locale"), # Local install +] + +# Global translator +_translator: gettext.GNUTranslations | gettext.NullTranslations | None = None +_current_lang: str = "system" + + +def _find_locale_dir() -> Path | None: + """Find the first existing locale directory.""" + for d in _LOCALE_DIRS: + if d.is_dir(): + return d + return None + + +def _get_system_lang() -> str: + """Get language code from system locale.""" + lang = os.environ.get("LANGUAGE") or os.environ.get("LANG", "en_US.UTF-8") + return lang.split(".")[0].split("_")[0] # e.g., "ru" from "ru_RU.UTF-8" + + +def init(lang: str = "system") -> None: + """Initialize translations. + + Args: + lang: Language code ("system", "en", "ru"). + "system" uses system locale. + """ + global _translator, _current_lang + _current_lang = lang + + # Get system locale + try: + locale.setlocale(locale.LC_ALL, "") + except locale.Error: + pass + + # Determine language to use + if lang == "system": + lang_code = _get_system_lang() + else: + lang_code = lang + + locale_dir = _find_locale_dir() + + if locale_dir: + try: + _translator = gettext.translation( + DOMAIN, + localedir=str(locale_dir), + languages=[lang_code], + fallback=True, + ) + except OSError: + _translator = gettext.NullTranslations() + else: + _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 + + +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) + + +# Initialize on import +init() diff --git a/src/vual/locale/en/LC_MESSAGES/vual.mo b/src/vual/locale/en/LC_MESSAGES/vual.mo new file mode 100644 index 0000000..feb9c2a Binary files /dev/null and b/src/vual/locale/en/LC_MESSAGES/vual.mo differ diff --git a/src/vual/locale/ru/LC_MESSAGES/vual.mo b/src/vual/locale/ru/LC_MESSAGES/vual.mo new file mode 100644 index 0000000..d303c25 Binary files /dev/null and b/src/vual/locale/ru/LC_MESSAGES/vual.mo differ diff --git a/src/vual/protonhax.py b/src/vual/protonhax.py new file mode 100644 index 0000000..a344b03 --- /dev/null +++ b/src/vual/protonhax.py @@ -0,0 +1,235 @@ +"""Integrated protonhax — list & run Proton games, manage init script.""" + +from __future__ import annotations + +import hashlib +import os +import re +import shutil +import subprocess +from pathlib import Path + +# ── Managed paths ──────────────────────────────────────────────── + +MANAGED_DIR = Path.home() / ".local" / "share" / "vual" / "bin" +MANAGED_PATH = MANAGED_DIR / "protonhax" + + +def _runtime_dir() -> Path: + xdg = os.environ.get("XDG_RUNTIME_DIR") + if xdg: + return Path(xdg) / "protonhax" + return Path(f"/run/user/{os.getuid()}") / "protonhax" + + +# ── List running games ────────────────────────────────────────── + + +def list_running() -> list[str]: + """Return app IDs of currently running Proton games.""" + phd = _runtime_dir() + if not phd.is_dir(): + return [] + return sorted(d.name for d in phd.iterdir() if d.is_dir() and d.name.isdigit()) + + +# ── Game context ──────────────────────────────────────────────── + +_ENV_RE = re.compile(r'^declare\s+-x\s+(\w+)="(.*)"$') + + +def _parse_bash_env(text: str) -> dict[str, str]: + env: dict[str, str] = {} + for line in text.splitlines(): + m = _ENV_RE.match(line) + if m: + env[m.group(1)] = m.group(2) + return env + + +def get_game_context(app_id: str) -> dict | None: + """Return {app_id, proton_exe, prefix, env} for a running game, or None.""" + game_dir = _runtime_dir() / app_id + if not game_dir.is_dir(): + return None + + ctx: dict = {"app_id": app_id} + + exe_file = game_dir / "exe" + if exe_file.exists(): + ctx["proton_exe"] = exe_file.read_text().strip() + + pfx_file = game_dir / "pfx" + if pfx_file.exists(): + ctx["prefix"] = pfx_file.read_text().strip() + + env_file = game_dir / "env" + if env_file.exists(): + ctx["env"] = _parse_bash_env(env_file.read_text()) + + return ctx + + +# ── Run in Proton context ─────────────────────────────────────── + + +def run_in_proton( + app_id: str, + executable: str, + args: list[str] | None = None, +) -> subprocess.Popen | None: + """Launch *executable* inside the Proton context of *app_id*. + + Args: + app_id: Steam app ID. + executable: Path to Windows executable. + args: Optional list of arguments to pass to the executable. + """ + ctx = get_game_context(app_id) + if not ctx or "proton_exe" not in ctx: + return None + + env = os.environ.copy() + if "env" in ctx: + env.update(ctx["env"]) + + proton_exe = ctx["proton_exe"] + cmd = [proton_exe, "run", executable] + if args: + cmd.extend(args) + return subprocess.Popen( + cmd, + env=env, + start_new_session=True, + ) + + +# ── Protonhax detection & management ──────────────────────────── + + +def find_installed() -> str | None: + """Return the path to protonhax (managed first, then system), or None.""" + if MANAGED_PATH.is_file(): + return str(MANAGED_PATH) + return shutil.which("protonhax") + + +def is_managed() -> bool: + """True if the managed copy exists.""" + return MANAGED_PATH.is_file() + + +def _script_hash(text: str) -> str: + return hashlib.sha256(text.encode()).hexdigest() + + +def needs_update() -> bool: + """True if the managed copy exists but differs from the bundled version.""" + if not MANAGED_PATH.is_file(): + return False + try: + installed = MANAGED_PATH.read_text() + except OSError: + return True + return _script_hash(installed) != _script_hash(INIT_SCRIPT) + + +def ensure_installed() -> Path: + """Install the managed protonhax if missing or outdated. Returns its path.""" + if not MANAGED_PATH.is_file() or needs_update(): + return install_init_script(MANAGED_PATH) + return MANAGED_PATH + + +# ── Bundled init script ───────────────────────────────────────── + +INIT_SCRIPT = r"""#!/bin/bash +# protonhax — managed by Vual +# https://github.com/jcnils/protonhax (original) + +phd=${XDG_RUNTIME_DIR:-/run/user/$UID}/protonhax + +usage() { + echo "Usage:" + echo "protonhax init " + printf "\tShould only be called by Steam with \"protonhax init %%COMMAND%%\"\n" + echo "protonhax ls" + printf "\tLists all currently running games\n" + echo "protonhax run " + printf "\tRuns in the context of with proton\n" + echo "protonhax cmd " + printf "\tRuns cmd.exe in the context of \n" + echo "protonhax exec " + printf "\tRuns in the context of \n" +} + +if [[ $# -lt 1 ]]; then + usage + exit 1 +fi + +c=$1 +shift + +if [[ "$c" == "init" ]]; then + mkdir -p "$phd/$SteamAppId" + printf "%s\n" "${@}" | grep -m 1 "/proton" > "$phd/$SteamAppId/exe" + printf "%s" "$STEAM_COMPAT_DATA_PATH/pfx" > "$phd/$SteamAppId/pfx" + declare -px > "$phd/$SteamAppId/env" + "$@" + ec=$? + rm -r "$phd/$SteamAppId" + exit $ec +elif [[ "$c" == "ls" ]]; then + if [[ -d "$phd" ]]; then + ls -1 "$phd" + fi +elif [[ "$c" == "run" ]] || [[ "$c" == "cmd" ]] || [[ "$c" == "exec" ]]; then + if [[ $# -lt 1 ]]; then + usage + exit 1 + fi + if [[ ! -d "$phd" ]]; then + printf "No app running with appid \"%s\"\n" "$1" + exit 2 + fi + if [[ ! -d "$phd/$1" ]]; then + printf "No app running with appid \"%s\"\n" "$1" + exit 2 + fi + SteamAppId=$1 + shift + + source "$phd/$SteamAppId/env" + + if [[ "$c" == "run" ]]; then + if [[ $# -lt 1 ]]; then + usage + exit 1 + fi + exec "$(cat "$phd/$SteamAppId/exe")" run "$@" + elif [[ "$c" == "cmd" ]]; then + exec "$(cat "$phd/$SteamAppId/exe")" run "$(cat "$phd/$SteamAppId/pfx")/drive_c/windows/system32/cmd.exe" + elif [[ "$c" == "exec" ]]; then + if [[ $# -lt 1 ]]; then + usage + exit 1 + fi + exec "$@" + fi +else + printf "Unknown command %s\n" "$c" + usage + exit 1 +fi +""" + + +def install_init_script(target: Path | None = None) -> Path: + """Write the protonhax shell script to *target* and make it executable.""" + if target is None: + target = MANAGED_PATH + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(INIT_SCRIPT) + target.chmod(0o755) + return target diff --git a/src/vual/steam.py b/src/vual/steam.py new file mode 100644 index 0000000..adfb757 --- /dev/null +++ b/src/vual/steam.py @@ -0,0 +1,538 @@ +"""Steam integration: VDF parsing, game discovery, and LaunchOptions management. + +Core functionality: +- VDF (Valve Data File) parsing and writing +- Steam game discovery from appmanifest files +- LaunchOptions manipulation in localconfig.vdf +- Steam process control (detection, shutdown, restart) +""" + +from __future__ import annotations + +import re +import subprocess +import time +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + +# ── VDF parser / writer ────────────────────────────────────────── + + +def parse_vdf(content: str) -> dict[str, object]: + """Parse Valve VDF text format into nested dictionaries. + + VDF is a key-value format used by Valve in Steam configuration files. + Supports nested sections (dictionaries) and string values. + + Args: + content: Raw VDF text content. + + Returns: + Nested dict structure representing the VDF data. + + Example VDF: + "UserLocalConfigStore" + { + "Software" + { + "Valve" "SomeValue" + } + } + """ + + def tokenize(text: str) -> list[tuple[str, str]]: + tokens: list[tuple[str, str]] = [] + i = 0 + while i < len(text): + if text[i].isspace(): + i += 1 + elif text[i : i + 2] == "//": + while i < len(text) and text[i] != "\n": + i += 1 + elif text[i] == '"': + i += 1 + start = i + while i < len(text) and text[i] != '"': + i += 1 + tokens.append(("S", text[start:i])) + i += 1 + elif text[i] in "{}": + tokens.append(("B", text[i])) + i += 1 + else: + i += 1 + return tokens + + def parse_tokens(tokens: list, idx: int = 0) -> tuple[dict, int]: + result: dict = {} + while idx < len(tokens): + tt, tv = tokens[idx] + if tt == "S": + key = tv + idx += 1 + if idx >= len(tokens): + break + nt, nv = tokens[idx] + if nt == "B" and nv == "{": + idx += 1 + nested, idx = parse_tokens(tokens, idx) + result[key] = nested + elif nt == "S": + result[key] = nv + idx += 1 + elif nt == "B" and nv == "}": + break + elif tt == "B" and tv == "}": + idx += 1 + break + else: + idx += 1 + return result, idx + + tokens = tokenize(content) + parsed, _ = parse_tokens(tokens) + return parsed + + +def write_vdf(data: dict[str, object], indent: int = 0) -> str: + """Serialize nested dictionaries into Valve VDF text format. + + Args: + data: Dictionary to serialize. + indent: Current indentation level (used internally for recursion). + + Returns: + VDF-formatted string. + """ + lines: list[str] = [] + tab = "\t" * indent + for key, value in data.items(): + if isinstance(value, dict): + lines.append(f'{tab}"{key}"') + lines.append(f"{tab}{{") + lines.append(write_vdf(value, indent + 1).rstrip()) + lines.append(f"{tab}}}") + else: + lines.append(f'{tab}"{key}"\t\t"{value}"') + return "\n".join(lines) + "\n" + + +# ════════════════════════════════════════════════════════════════ +# Config Discovery +# ════════════════════════════════════════════════════════════════ + + +def find_localconfig_vdf(steam_path: str) -> Path | None: + """Find the first localconfig.vdf file in Steam's userdata directory. + + This file contains per-user Steam settings including LaunchOptions + for each game. + + Args: + steam_path: Path to Steam installation directory (can use ~). + + Returns: + Path to localconfig.vdf if found, None otherwise. + """ + userdata = Path(steam_path).expanduser() / "userdata" + if not userdata.exists(): + return None + for cfg in userdata.rglob("localconfig.vdf"): + return cfg + return None + + +# ════════════════════════════════════════════════════════════════ +# Game Manifest Reading +# ════════════════════════════════════════════════════════════════ + +# Cache for compiled regex patterns to avoid recompilation +_EXCLUDE_PATTERNS_CACHE: dict[tuple[str, ...], list[re.Pattern[str]]] = {} + + +def _get_compiled_patterns(patterns: Sequence[str] | None) -> list[re.Pattern[str]]: + """Compile and cache exclusion regex patterns.""" + if not patterns: + return [] + key = tuple(patterns) + if key not in _EXCLUDE_PATTERNS_CACHE: + _EXCLUDE_PATTERNS_CACHE[key] = [re.compile(p, re.IGNORECASE) for p in patterns] + return _EXCLUDE_PATTERNS_CACHE[key] + + +def is_app_excluded(app_name: str, patterns: Sequence[str] | None) -> bool: + """Check if app name matches any exclusion pattern. + + Args: + app_name: Name of the Steam app. + patterns: List of regex patterns to check against. + + Returns: + True if app should be excluded. + """ + return any(pat.search(app_name) for pat in _get_compiled_patterns(patterns)) + + +def get_game_info(app_id: str, steamapps: Path) -> dict[str, str]: + """Read game information from appmanifest_.acf. + + Args: + app_id: Steam app ID. + steamapps: Path to steamapps directory. + + Returns: + Dict with keys: app_id, name, install_dir. + """ + info: dict[str, str] = {"app_id": app_id, "name": f"Game {app_id}", "install_dir": ""} + manifest = steamapps / f"appmanifest_{app_id}.acf" + if not manifest.exists(): + return info + + try: + text = manifest.read_text() + except OSError: + return info + + for line in text.splitlines(): + line = line.strip() + if line.startswith('"name"'): + parts = line.split('"', 3) + if len(parts) >= 4: + info["name"] = parts[3].rstrip('"') + elif line.startswith('"installdir"'): + parts = line.split('"', 3) + if len(parts) >= 4: + info["install_dir"] = parts[3].rstrip('"') + return info + + +def get_installed_games( + steamapps: Path, + excluded_patterns: Sequence[str] | None = None, +) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + """Get lists of installed Steam games. + + Args: + steamapps: Path to steamapps directory. + excluded_patterns: Regex patterns for apps to exclude. + + Returns: + Tuple of (included_games, excluded_games), sorted by name. + """ + included: list[dict[str, str]] = [] + excluded: list[dict[str, str]] = [] + + if not steamapps.exists(): + return included, excluded + for mf in steamapps.glob("appmanifest_*.acf"): + aid = mf.stem.replace("appmanifest_", "") + if not aid.isdigit(): + continue + info = get_game_info(aid, steamapps) + if is_app_excluded(info["name"], excluded_patterns): + excluded.append(info) + else: + included.append(info) + included.sort(key=lambda g: g["name"].lower()) + excluded.sort(key=lambda g: g["name"].lower()) + return included, excluded + + +# ════════════════════════════════════════════════════════════════ +# LaunchOptions Management +# ════════════════════════════════════════════════════════════════ + +# VDF path to the apps section +_VDF_APP_PATH = ["UserLocalConfigStore", "Software", "Valve", "Steam", "apps"] + + +def _navigate_to_apps(data: dict[str, object]) -> dict[str, object] | None: + """Navigate parsed VDF dict to the apps section. + + Path: UserLocalConfigStore -> Software -> Valve -> Steam -> apps + """ + try: + root = data.get("UserLocalConfigStore", data) + return root.get("Software", {}).get("Valve", {}).get("Steam", {}).get("apps", {}) + except (TypeError, AttributeError): + return None + + +def _navigate_vdf_lines(lines: list[str], path: list[str]) -> tuple[int, int]: + """Navigate VDF text lines to find a section by key path. + + Args: + lines: VDF file content split by lines. + path: List of section keys to navigate. + + Returns: + Tuple of (brace_line_index, brace_depth) for the opening brace + of the target section, or (-1, -1) if not found. + """ + target_idx = 0 + brace_depth = 0 + pending_key: str | None = None + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith("//"): + continue + + if stripped == "{": + brace_depth += 1 + if ( + pending_key is not None + and target_idx < len(path) + and pending_key == path[target_idx] + and brace_depth == target_idx + 1 + ): + target_idx += 1 + if target_idx == len(path): + return i, brace_depth + pending_key = None + continue + + if stripped == "}": + brace_depth -= 1 + pending_key = None + continue + + m = re.match(r'^"([^"]*)"', stripped) + if not m: + pending_key = None + continue + + rest = stripped[m.end() :].strip() + if rest: + # key-value pair on one line + pending_key = None + else: + # section header — next line should be "{" + pending_key = m.group(1) + + return -1, -1 + + +def get_launch_options(app_id: str, localconfig_path: Path) -> str | None: + """Return LaunchOptions string for app_id, or None if not set.""" + try: + data = parse_vdf(localconfig_path.read_text()) + except OSError: + return None + apps = _navigate_to_apps(data) + if not apps: + return None + section = apps.get(app_id) if isinstance(apps, dict) else None + if isinstance(section, dict): + return section.get("LaunchOptions") + return None + + +def set_launch_options(app_id: str, value: str, localconfig_path: Path) -> bool: + """Set LaunchOptions for *app_id* via surgical text edit (no full reserialization).""" + try: + text = localconfig_path.read_text() + except OSError: + return False + + old = get_launch_options(app_id, localconfig_path) + if old and old != value: + _create_backup(app_id, old) + + lines = text.splitlines(keepends=True) + app_path = _VDF_APP_PATH + [app_id] + brace_idx, brace_depth = _navigate_vdf_lines(lines, app_path) + + if brace_idx >= 0: + # App section exists — find LaunchOptions or closing '}' + launch_idx = -1 + close_idx = -1 + depth = brace_depth + + for i in range(brace_idx + 1, len(lines)): + stripped = lines[i].strip() + if not stripped or stripped.startswith("//"): + continue + if stripped == "{": + depth += 1 + continue + if stripped == "}": + if depth == brace_depth: + close_idx = i + break + depth -= 1 + continue + if depth == brace_depth: + km = re.match(r'^"([^"]*)"', stripped) + if km and km.group(1) == "LaunchOptions": + launch_idx = i + break + + if launch_idx >= 0: + old_line = lines[launch_idx] + indent = old_line[: len(old_line) - len(old_line.lstrip())] + lines[launch_idx] = f'{indent}"LaunchOptions"\t\t"{value}"\n' + elif close_idx >= 0: + cl = lines[close_idx] + indent = cl[: len(cl) - len(cl.lstrip())] + "\t" + lines.insert(close_idx, f'{indent}"LaunchOptions"\t\t"{value}"\n') + else: + return False + else: + # App section missing — create it inside "apps" + apps_idx, apps_depth = _navigate_vdf_lines(lines, _VDF_APP_PATH) + if apps_idx < 0: + return False + ai = lines[apps_idx][: len(lines[apps_idx]) - len(lines[apps_idx].lstrip())] + ki = ai + "\t" + vi = ki + "\t" + block = [ + f'{ki}"{app_id}"\n', + f"{ki}{{\n", + f'{vi}"LaunchOptions"\t\t"{value}"\n', + f"{ki}}}\n", + ] + for j, nl in enumerate(block): + lines.insert(apps_idx + 1 + j, nl) + + try: + localconfig_path.write_text("".join(lines)) + return True + except OSError: + return False + + +def remove_launch_options(app_id: str, localconfig_path: Path) -> bool: + """Remove LaunchOptions for *app_id* via surgical text edit.""" + try: + text = localconfig_path.read_text() + except OSError: + return False + + old = get_launch_options(app_id, localconfig_path) + if not old: + return False + + _create_backup(app_id, old) + + lines = text.splitlines(keepends=True) + app_path = _VDF_APP_PATH + [app_id] + brace_idx, brace_depth = _navigate_vdf_lines(lines, app_path) + if brace_idx < 0: + return False + + depth = brace_depth + for i in range(brace_idx + 1, len(lines)): + stripped = lines[i].strip() + if not stripped or stripped.startswith("//"): + continue + if stripped == "{": + depth += 1 + continue + if stripped == "}": + if depth == brace_depth: + break + depth -= 1 + continue + if depth == brace_depth: + km = re.match(r'^"([^"]*)"', stripped) + if km and km.group(1) == "LaunchOptions": + del lines[i] + try: + localconfig_path.write_text("".join(lines)) + return True + except OSError: + return False + return False + + +def _create_backup(game_id: str, original: str) -> None: + """Create a backup file with the original LaunchOptions value.""" + backup_dir = Path.home() / ".local" / "share" / "vual" / "backups" + backup_dir.mkdir(parents=True, exist_ok=True) + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + backup = backup_dir / f"launch_options_backup_{ts}.md" + line = f"| {game_id} | {original} |\n" + + if backup.exists(): + with open(backup, "a") as f: + f.write(line) + else: + with open(backup, "w") as f: + f.write("# Launch Options Backup\n\n") + f.write(f"Generated: {datetime.now().isoformat()}\n\n") + f.write("| Game ID | Original LaunchOptions |\n") + f.write("|---------|------------------------|\n") + f.write(line) + + +# ════════════════════════════════════════════════════════════════ +# Steam Process Control +# ════════════════════════════════════════════════════════════════ + + +def is_steam_running() -> bool: + """Check if the Steam client process is currently running. + + Uses /proc filesystem to detect Steam process. + Excludes steamwebhelper processes. + + Returns: + True if Steam main process is running. + """ + proc = Path("/proc") + if not proc.is_dir(): + return False + for pid_dir in proc.iterdir(): + if not pid_dir.name.isdigit(): + continue + try: + cmdline = (pid_dir / "cmdline").read_bytes() + except OSError: + continue + first_arg = cmdline.split(b"\x00", 1)[0] + if first_arg.endswith(b"/steam") and b"steamwebhelper" not in cmdline: + return True + return False + + +def shutdown_steam() -> None: + """Request a graceful Steam shutdown via CLI command.""" + subprocess.Popen( + ["steam", "-shutdown"], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def wait_steam_exit(timeout: float = 30.0) -> bool: + """Wait for Steam to exit. + + Args: + timeout: Maximum time to wait in seconds. + + Returns: + True if Steam exited, False if timeout reached. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not is_steam_running(): + return True + time.sleep(0.5) + return False + + +def start_steam() -> None: + """Launch Steam in the background.""" + subprocess.Popen( + ["steam"], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/src/vual/ui/__init__.py b/src/vual/ui/__init__.py new file mode 100644 index 0000000..ba87816 --- /dev/null +++ b/src/vual/ui/__init__.py @@ -0,0 +1 @@ +"""Vual UI sub-package.""" diff --git a/src/vual/ui/main_page.py b/src/vual/ui/main_page.py new file mode 100644 index 0000000..15eb406 --- /dev/null +++ b/src/vual/ui/main_page.py @@ -0,0 +1,816 @@ +"""Main games grid page. + +Clean rewrite using Gtk.FlowBox + simple Gtk.Overlay tiles. +""" + +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 + +import requests + +from vual import protonhax, steam +from vual.config import TILE_SIZES, Config +from vual.i18n import _ + +if TYPE_CHECKING: + from vual.window import VualWindow + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 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 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# MainPage — the main games grid +# ═══════════════════════════════════════════════════════════════════════════════ + +class MainPage(Adw.Bin): + """Main page with games grid.""" + + __gtype_name__ = "VualMainPage" + + def __init__(self, config: Config, window: VualWindow): + super().__init__() + + self._win = window + self._cfg = config + self._tiles: dict[str, GameTile] = {} + self._search_text = "" + self._loading = False + self._auto_ce: str | None = None + self._executor = ThreadPoolExecutor(max_workers=8) + + # Search entry (exposed for header bar) + self._search = Gtk.SearchEntry() + self._search.set_placeholder_text(_("Search...")) + self._search.connect("search-changed", self._on_search_changed) + + # Build UI + self._build_ui() + + # Load games on startup + GLib.idle_add(self._load) + + def _build_ui(self) -> None: + """Build the main page UI.""" + # Stack for loading/content states + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.set_child(self._stack) + + # Loading spinner + spinner = Gtk.Spinner(spinning=True) + spinner.set_size_request(48, 48) + spinner.set_valign(Gtk.Align.CENTER) + spinner.set_halign(Gtk.Align.CENTER) + self._stack.add_named(spinner, "loading") + + # Empty state + empty = Adw.StatusPage( + icon_name="view-grid-symbolic", + title=_("Games not found"), + description=_("Check Steam path in settings"), + ) + self._stack.add_named(empty, "empty") + + # Scrolled grid + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + + # FlowBox grid + self._grid = Gtk.FlowBox() + self._grid.set_homogeneous(False) + self._grid.set_valign(Gtk.Align.START) + self._grid.set_halign(Gtk.Align.CENTER) + self._grid.set_row_spacing(GRID_SPACING) + self._grid.set_column_spacing(GRID_SPACING) + self._grid.set_selection_mode(Gtk.SelectionMode.NONE) + self._grid.set_min_children_per_line(1) + self._grid.set_max_children_per_line(20) + self._grid.set_margin_start(GRID_MARGIN) + self._grid.set_margin_end(GRID_MARGIN) + self._grid.set_margin_top(GRID_MARGIN) + self._grid.set_margin_bottom(GRID_MARGIN) + self._grid.add_css_class("vual-grid") + self._grid.set_filter_func(self._filter_func) + self._grid.set_sort_func(self._sort_func) + + scroll.set_child(self._grid) + self._stack.add_named(scroll, "games") + + self._stack.set_visible_child_name("loading") + + # ─── Public API ─────────────────────────────────────────────────────────── + + def reload(self) -> None: + """Reload games list.""" + self._load_games() + + # Alias for window.py compatibility + _load = reload + + def search(self, text: str) -> None: + """Filter games by search text.""" + self._search_text = text.lower().strip() + self._grid.invalidate_filter() + + def _on_search_changed(self, entry: Gtk.SearchEntry) -> None: + """Handle search entry changes.""" + self.search(entry.get_text()) + + def apply_tile_size(self) -> None: + """Apply current tile size from config.""" + width = TILE_SIZES.get(self._cfg.tile_size, 150) + for tile in self._tiles.values(): + tile.resize(width) + # Reload cover at new size + self._load_cover(tile) + + def apply_sort(self) -> None: + """Reapply sort order.""" + self._grid.invalidate_sort() + + # Alias for window.py compatibility + _update_sort = apply_sort + + def update_running_state(self, running_ids: set[str]) -> None: + """Update running badges based on active processes.""" + 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: + """Filter function for FlowBox.""" + if not self._search_text: + return True + tile = child.get_child() + if isinstance(tile, GameTile): + return self._search_text in tile.name.lower() + return True + + def _sort_func(self, a: Gtk.FlowBoxChild, b: Gtk.FlowBoxChild) -> int: + """Sort function for FlowBox.""" + tile_a = a.get_child() + tile_b = b.get_child() + if not isinstance(tile_a, GameTile) or not isinstance(tile_b, GameTile): + return 0 + + if self._cfg.sort_by == "status": + # Active first, then by name + if tile_a.is_active != tile_b.is_active: + return -1 if tile_a.is_active else 1 + + # Alphabetical + return (tile_a.name.lower() > tile_b.name.lower()) - (tile_a.name.lower() < tile_b.name.lower()) + + # ─── Loading ────────────────────────────────────────────────────────────── + + def _load_games(self) -> None: + """Load games from Steam in background thread.""" + if self._loading: + return + self._loading = True + self._stack.set_visible_child_name("loading") + + def worker(): + steamapps = self._cfg.steamapps_path + if not steamapps.exists(): + GLib.idle_add(self._on_games_loaded, []) + return + + # Get installed games using steam module + included, _ = steam.get_installed_games( + steamapps, self._cfg.excluded_app_patterns + ) + + # Check activation status for each game + localconfig = steam.find_localconfig_vdf(self._cfg.steam_path) + games = [] + for game in included: + app_id = game["app_id"] + name = game["name"] + + is_active = False + if localconfig: + opts = steam.get_launch_options(app_id, localconfig) + if opts and "protonhax" in opts.lower(): + is_active = True + + games.append((app_id, name, is_active)) + + GLib.idle_add(self._on_games_loaded, games) + + threading.Thread(target=worker, daemon=True).start() + + def _on_games_loaded(self, games: list[tuple[str, str, bool]]) -> None: + """Handle loaded games on main thread.""" + self._loading = False + + # Clear existing tiles + while child := self._grid.get_first_child(): + self._grid.remove(child) + self._tiles.clear() + + if not games: + self._stack.set_visible_child_name("empty") + self._win.update_counter(0) + return + + # Get tile size + width = TILE_SIZES.get(self._cfg.tile_size, 150) + + # Create tiles + for app_id, name, is_active in games: + tile = GameTile(app_id, name, is_active, width) + tile.connect_launch(self._on_launch) + tile.connect_launch_ce(self._on_launch_ce) + tile.connect_toggle(self._on_toggle) + tile.connect_reload(self._on_reload_cover) + + self._tiles[app_id] = tile + self._grid.append(tile) + + # Load cover in background + self._load_cover(tile) + + self._grid.invalidate_sort() + self._stack.set_visible_child_name("games") + self._win.update_counter(len(games)) + + # Check running games + self._check_running() + + def _load_cover(self, tile: GameTile) -> None: + """Load cover image for tile in background.""" + app_id = tile.app_id + width = tile._width + height = tile._height + + def worker(): + path = _download_cover(app_id) + if path: + pixbuf = _load_pixbuf(path, width, height) + GLib.idle_add(tile.set_cover, pixbuf) + else: + GLib.idle_add(tile.set_cover, None) + + self._executor.submit(worker) + + def _on_reload_cover(self, app_id: str) -> None: + """Force reload cover from Steam.""" + tile = self._tiles.get(app_id) + if not tile: + return + + # Delete cached cover + cache = _cache_path(app_id) + if cache.exists(): + cache.unlink() + + # Re-add skeleton class and reload + tile._picture.add_css_class("skeleton") + tile._picture.set_paintable(None) + self._load_cover(tile) + + # ─── Running detection ──────────────────────────────────────────────────── + + def _check_running(self) -> None: + """Check which games are currently running.""" + def worker(): + running_ids = set(protonhax.list_running()) + GLib.idle_add(self._on_running_checked, running_ids) + + threading.Thread(target=worker, daemon=True).start() + + def _on_running_checked(self, running_ids: set[str]) -> None: + """Update running state on main thread.""" + self.update_running_state(running_ids) + + # Auto-launch CE if needed + if self._auto_ce and self._auto_ce in running_ids: + app_id = self._auto_ce + tile = self._tiles.get(app_id) + name = tile.name if tile else app_id + self._auto_ce = None + GLib.timeout_add_seconds(2, lambda: self._do_ce(app_id, name)) + + # Schedule next check + GLib.timeout_add_seconds(5, self._check_running) + + # ─── Actions ────────────────────────────────────────────────────────────── + + def _on_launch(self, app_id: str, name: str) -> None: + """Launch game via Steam.""" + try: + protonhax.ensure_installed() + except OSError: + pass + + subprocess.Popen( + ["steam", f"steam://rungameid/{app_id}"], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self._win.show_toast(_("Launching %s...") % name) + self._auto_ce = app_id + + def _on_launch_ce(self, app_id: str, name: str) -> None: + """Launch CE for a game.""" + self._do_ce(app_id, name) + + def _do_ce(self, app_id: str, name: str) -> None: + """Actually launch Cheat Engine.""" + if not self._cfg.ce_exists: + self._win.show_toast(_("CE not found: %s") % self._cfg.ce_executable) + return + + args = None + if self._cfg.ce_language != "system": + args = ["--LANG", self._cfg.ce_language] + + proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args) + 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_toggle(self, app_id: str, active: bool) -> None: + """Handle activation toggle.""" + tile = self._tiles.get(app_id) + if not tile: + return + + if active: + self._apply_single(app_id, tile.name, "set") + else: + self._apply_single(app_id, tile.name, "remove") + + def _apply_single(self, app_id: str, name: str, mode: str) -> None: + """Apply launch options for a single game.""" + if steam.is_steam_running(): + self._win.show_toast(_("Close Steam to change settings")) + # Revert switch + tile = self._tiles.get(app_id) + if tile: + tile.set_active_silent(mode != "set") + return + + try: + protonhax.ensure_installed() + except OSError as e: + self._win.show_toast(f"protonhax: {e}") + return + + localconfig = steam.find_localconfig_vdf(self._cfg.steam_path) + if not localconfig: + self._win.show_toast(_("localconfig.vdf not found")) + return + + if mode == "set": + ok = steam.set_launch_options(app_id, self._cfg.launch_options_template, localconfig) + verb = _("set") + else: + ok = steam.remove_launch_options(app_id, localconfig) + verb = _("removed") + + if ok: + self._win.show_toast(_("LaunchOptions %s for %s") % (verb, name)) + else: + self._win.show_toast(_("Error changing settings for %s") % name) + # Revert switch + tile = self._tiles.get(app_id) + if tile: + tile.set_active_silent(mode != "set") + + # ─── Batch operations ───────────────────────────────────────────────────── + + def _on_enable_all(self, _btn) -> None: + """Show dialog for enabling all games.""" + dlg = Adw.AlertDialog( + heading=_("Enable for all games?"), + body=self._cfg.launch_options_template, + ) + dlg.add_response("cancel", _("Cancel")) + dlg.add_response("ok", _("Enable")) + dlg.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED) + dlg.choose(self._win, None, self._finish_enable) + + def _finish_enable(self, dlg, res) -> None: + if dlg.choose_finish(res) != "ok": + return + try: + protonhax.ensure_installed() + except OSError as e: + self._win.show_toast(f"protonhax: {e}") + return + ids = [aid for aid, t in self._tiles.items() if not t.is_active] + if ids: + self._apply_batch(ids, "set") + + def _on_disable_all(self, _btn) -> None: + """Show dialog for disabling all games.""" + dlg = Adw.AlertDialog( + heading=_("Disable for all games?"), + body=_("LaunchOptions will be removed."), + ) + dlg.add_response("cancel", _("Cancel")) + dlg.add_response("ok", _("Disable")) + dlg.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE) + dlg.choose(self._win, None, self._finish_disable) + + def _finish_disable(self, dlg, res) -> None: + if dlg.choose_finish(res) != "ok": + return + ids = [aid for aid, t in self._tiles.items() if t.is_active] + if ids: + self._apply_batch(ids, "remove") + + def _apply_batch(self, ids: list[str], mode: str) -> None: + """Apply changes to multiple games.""" + if steam.is_steam_running(): + self._win.show_toast(_("Restarting Steam to apply...")) + + def wait_and_apply(): + steam.shutdown_steam() + if not steam.wait_steam_exit(30): + GLib.idle_add(self._win.show_toast, _("Steam did not exit")) + return + GLib.idle_add(self._write_batch, ids, mode, True) + + threading.Thread(target=wait_and_apply, daemon=True).start() + else: + self._write_batch(ids, mode, False) + + def _write_batch(self, ids: list[str], mode: str, restart: bool) -> None: + """Write launch options for multiple games.""" + localconfig = steam.find_localconfig_vdf(self._cfg.steam_path) + if not localconfig: + self._win.show_toast(_("localconfig.vdf not found")) + return + + ok = 0 + for app_id in ids: + if mode == "set": + result = steam.set_launch_options(app_id, self._cfg.launch_options_template, localconfig) + else: + result = steam.remove_launch_options(app_id, localconfig) + + if result: + ok += 1 + tile = self._tiles.get(app_id) + if tile: + tile.set_active_silent(mode == "set") + + if mode == "set": + self._win.show_toast(_("LaunchOptions set for %d games") % ok) + else: + self._win.show_toast(_("LaunchOptions removed for %d games") % ok) + + 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 diff --git a/src/vual/ui/preferences.py b/src/vual/ui/preferences.py new file mode 100644 index 0000000..2122854 --- /dev/null +++ b/src/vual/ui/preferences.py @@ -0,0 +1,783 @@ +"""Preferences window — clean libadwaita implementation. + +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 vual import cheatengine, protonhax, wine_theme +from vual.config import Config +from vual.i18n import _ + + +class PreferencesWindow(Adw.PreferencesWindow): + """Preferences window with auto-save.""" + + def __init__(self, config: Config, **kwargs) -> None: + super().__init__( + title=_("Preferences"), + **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._build_pages() + self._refresh_status() + self._building = False + + def _save(self) -> None: + """Save config if not building UI.""" + if self._building: + return + self._config.save() + + # ════════════════════════════════════════════════════════════════ + # Pages + # ════════════════════════════════════════════════════════════════ + + 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() + 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"))) diff --git a/src/vual/window.py b/src/vual/window.py new file mode 100644 index 0000000..fe5e0c0 --- /dev/null +++ b/src/vual/window.py @@ -0,0 +1,128 @@ +"""Main application window — single-page tile grid.""" + +from __future__ import annotations + +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 APP_NAME # noqa: E402 +from vual.config import Config # noqa: E402 +from vual.i18n import _ # noqa: E402 + + +class VualWindow(Adw.ApplicationWindow): + def __init__(self, config: Config, **kwargs) -> None: + super().__init__( + default_width=config.window_width, + default_height=config.window_height, + title=APP_NAME, + **kwargs, + ) + self.config = config + self._build_ui() + + # ── UI construction ────────────────────────────────────────── + + def _build_ui(self) -> None: + self.toast_overlay = Adw.ToastOverlay() + self.set_content(self.toast_overlay) + + toolbar_view = Adw.ToolbarView() + self.toast_overlay.set_child(toolbar_view) + + # Single main page + from vual.ui.main_page import MainPage + + self.main_page = MainPage(config=self.config, window=self) + + # Header bar + header = Adw.HeaderBar() + header.add_css_class("flat") + + # Title widget: search + counter + title_box = Gtk.Box(spacing=8) + title_box.append(self.main_page._search) + self._counter = Gtk.Label(css_classes=["games-counter"]) + title_box.append(self._counter) + header.set_title_widget(title_box) + + b_refresh = Gtk.Button( + icon_name="view-refresh-symbolic", + css_classes=["flat", "header-btn"], + tooltip_text=_("Refresh"), + ) + b_refresh.connect("clicked", lambda _: self.main_page._load()) + header.pack_start(b_refresh) + + b_on = Gtk.Button( + icon_name="object-select-symbolic", + css_classes=["flat", "header-btn"], + tooltip_text=_("Enable All"), + ) + b_on.connect("clicked", self.main_page._on_enable_all) + header.pack_start(b_on) + + b_off = Gtk.Button( + icon_name="edit-clear-all-symbolic", + css_classes=["flat", "header-btn"], + tooltip_text=_("Disable All"), + ) + b_off.connect("clicked", self.main_page._on_disable_all) + header.pack_start(b_off) + + # Sort menu + sort_menu = Gio.Menu() + sort_menu.append(_("By name"), "win.sort::name") + sort_menu.append(_("By status"), "win.sort::status") + + sort_btn = Gtk.MenuButton( + icon_name="view-sort-descending-symbolic", + css_classes=["flat", "header-btn"], + menu_model=sort_menu, + tooltip_text=_("Sort"), + ) + header.pack_end(sort_btn) + + # Sort action + sort_action = Gio.SimpleAction.new("sort", GLib.VariantType.new("s")) + sort_action.connect("activate", self._on_sort_changed) + self.add_action(sort_action) + + menu_button = Gtk.MenuButton( + icon_name="open-menu-symbolic", + css_classes=["flat", "header-btn"], + menu_model=self._build_menu(), + ) + header.pack_end(menu_button) + toolbar_view.add_top_bar(header) + + toolbar_view.set_content(self.main_page) + + 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() + + def update_counter(self, count: int) -> None: + """Update the games counter label.""" + self._counter.set_label(_("%d games") % count) + + # ── App menu ───────────────────────────────────────────────── + + def _build_menu(self) -> Gio.Menu: + menu = Gio.Menu() + menu.append(_("Preferences"), "app.preferences") + menu.append(_("About"), "app.about") + menu.append(_("Quit"), "app.quit") + return menu + + # ── Toast helper ───────────────────────────────────────────── + + def show_toast(self, message: str, timeout: int = 3) -> None: + toast = Adw.Toast(title=message, timeout=timeout) + self.toast_overlay.add_toast(toast) + return False diff --git a/src/vual/wine_theme.py b/src/vual/wine_theme.py new file mode 100644 index 0000000..08f4823 --- /dev/null +++ b/src/vual/wine_theme.py @@ -0,0 +1,406 @@ +"""Wine theme management for Proton prefixes. + +Safely applies dark/light color schemes to Wine prefixes via registry. +Only modifies string and dword values — never touches binary/hex data. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Iterator + +# Registry section pattern +_SECTION_RE = re.compile(r'^\[([^\]]+)\]', re.MULTILINE) + +# Wine dark color scheme (Control Panel\Colors) +# Format: "R G B" strings +DARK_COLORS = { + "ActiveBorder": "49 54 59", + "ActiveTitle": "49 54 59", + "AppWorkSpace": "30 30 30", + "Background": "30 30 30", + "ButtonAlternateFace": "49 54 59", + "ButtonDkShadow": "20 20 20", + "ButtonFace": "49 54 59", + "ButtonHilight": "70 75 80", + "ButtonLight": "60 65 70", + "ButtonShadow": "35 38 41", + "ButtonText": "230 230 230", + "GradientActiveTitle": "49 54 59", + "GradientInactiveTitle": "40 42 45", + "GrayText": "128 128 128", + "Hilight": "61 174 233", + "HilightText": "255 255 255", + "HotTrackingColor": "61 174 233", + "InactiveBorder": "40 42 45", + "InactiveTitle": "40 42 45", + "InactiveTitleText": "128 128 128", + "InfoText": "230 230 230", + "InfoWindow": "49 54 59", + "Menu": "49 54 59", + "MenuBar": "49 54 59", + "MenuHilight": "61 174 233", + "MenuText": "230 230 230", + "Scrollbar": "49 54 59", + "TitleText": "230 230 230", + "Window": "35 38 41", + "WindowFrame": "49 54 59", + "WindowText": "230 230 230", +} + +# Standard light colors (Windows defaults) +LIGHT_COLORS = { + "ActiveBorder": "180 180 180", + "ActiveTitle": "153 180 209", + "AppWorkSpace": "171 171 171", + "Background": "0 0 0", + "ButtonAlternateFace": "0 0 0", + "ButtonDkShadow": "105 105 105", + "ButtonFace": "240 240 240", + "ButtonHilight": "255 255 255", + "ButtonLight": "227 227 227", + "ButtonShadow": "160 160 160", + "ButtonText": "0 0 0", + "GradientActiveTitle": "185 209 234", + "GradientInactiveTitle": "215 228 242", + "GrayText": "109 109 109", + "Hilight": "0 120 215", + "HilightText": "255 255 255", + "HotTrackingColor": "0 102 204", + "InactiveBorder": "244 247 252", + "InactiveTitle": "191 205 219", + "InactiveTitleText": "0 0 0", + "InfoText": "0 0 0", + "InfoWindow": "255 255 225", + "Menu": "240 240 240", + "MenuBar": "240 240 240", + "MenuHilight": "0 120 215", + "MenuText": "0 0 0", + "Scrollbar": "200 200 200", + "TitleText": "0 0 0", + "Window": "255 255 255", + "WindowFrame": "100 100 100", + "WindowText": "0 0 0", +} + +# Color scheme presets +COLOR_SCHEMES = { + "dark": DARK_COLORS, + "light": LIGHT_COLORS, +} + +# DWM (Desktop Window Manager) settings for dark theme +# AccentColor is ABGR format as dword +DARK_DWM = { + "AccentColor": 0xff3b3b3b, # Dark gray accent + "AccentColorInactive": 0xff2d2d2d, # Darker inactive + "ColorizationAfterglow": 0xc43b3b3b, + "ColorizationColor": 0xc43b3b3b, + "ColorizationColorBalance": 0x59, + "ColorizationGlassAttribute": 0x01, + "ColorPrevalence": 0x01, # Use accent color on title bars + "EnableWindowColorization": 0x01, +} + +LIGHT_DWM = { + "AccentColor": 0xffd77800, # Blue accent (Windows default) + "AccentColorInactive": 0xffdbdbdb, + "ColorizationAfterglow": 0xc44f8bcd, + "ColorizationColor": 0xc44f8bcd, + "ColorizationColorBalance": 0x59, + "ColorizationGlassAttribute": 0x01, + "ColorPrevalence": 0x00, + "EnableWindowColorization": 0x01, +} + +DWM_SCHEMES = { + "dark": DARK_DWM, + "light": LIGHT_DWM, +} + +# Explorer accent settings +DARK_EXPLORER = { + "AccentColorMenu": 0xff3b3b3b, +} + +LIGHT_EXPLORER = { + "AccentColorMenu": 0xffd77800, +} + +EXPLORER_SCHEMES = { + "dark": DARK_EXPLORER, + "light": LIGHT_EXPLORER, +} + +# Vual's own Wine prefix +VUAL_PREFIX = Path.home() / ".local" / "share" / "vual" / "wine_prefix" + + +def get_vual_prefix() -> Path | None: + """Return Vual's Wine prefix if it exists.""" + if VUAL_PREFIX.is_dir() and (VUAL_PREFIX / "user.reg").is_file(): + return VUAL_PREFIX + return None + + +def get_all_prefixes(steamapps: Path) -> Iterator[Path]: + """Yield all Proton prefix paths under steamapps/compatdata.""" + compatdata = steamapps / "compatdata" + if not compatdata.is_dir(): + return + + for entry in compatdata.iterdir(): + if not entry.is_dir(): + continue + # Skip non-numeric (not app IDs) + if not entry.name.isdigit(): + continue + + pfx = entry / "pfx" + if pfx.is_dir(): + yield pfx + + +def _find_or_create_section(lines: list[str], section_name: str) -> int: + """Find section index or create it. Returns index of first line after section header. + + Wine registry sections can have timestamps: [Section\\Name] 1234567890 + We match by prefix to handle this. + """ + target = f"[{section_name}]" + target_lower = target.lower() + + for i, line in enumerate(lines): + stripped = line.strip().lower() + # Match exact or with timestamp suffix + if stripped == target_lower or stripped.startswith(target_lower + " "): + return i + 1 + + # Section not found — add at end + if lines and lines[-1].strip(): + lines.append("") + lines.append(target) + lines.append("") + return len(lines) - 1 + + +def _set_string_value(lines: list[str], section_idx: int, key: str, value: str) -> None: + """Set a string value in registry section. Only modifies string values.""" + # Find section end (next section or EOF) + section_end = len(lines) + for i in range(section_idx, len(lines)): + if lines[i].startswith("["): + section_end = i + break + + # Look for existing key + key_pattern = f'"{key}"=' + for i in range(section_idx, section_end): + if lines[i].startswith(key_pattern): + lines[i] = f'"{key}"="{value}"' + return + + # Key not found — insert before section end + insert_at = section_end + # Find last non-empty line in section + for i in range(section_end - 1, section_idx - 1, -1): + if lines[i].strip(): + insert_at = i + 1 + break + + lines.insert(insert_at, f'"{key}"="{value}"') + + +def _set_dword_value(lines: list[str], section_idx: int, key: str, value: int) -> None: + """Set a dword value in registry section.""" + # Find section end (next section or EOF) + section_end = len(lines) + for i in range(section_idx, len(lines)): + if lines[i].startswith("["): + section_end = i + break + + # Format: "Key"=dword:00000000 + dword_str = f'"{key}"=dword:{value:08x}' + + # Look for existing key + key_pattern = f'"{key}"=' + for i in range(section_idx, section_end): + if lines[i].startswith(key_pattern): + lines[i] = dword_str + return + + # Key not found — insert before section end + insert_at = section_end + for i in range(section_end - 1, section_idx - 1, -1): + if lines[i].strip(): + insert_at = i + 1 + break + + lines.insert(insert_at, dword_str) + + +def apply_colors_to_prefix(pfx: Path, colors: dict[str, str], is_dark: bool) -> bool: + """Apply color scheme to a single prefix. + + Modifies: + - [Control Panel\\Colors] — color values as "R G B" strings + - [Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize] — dark mode flags + + Args: + pfx: Path to prefix directory (containing user.reg). + colors: Dict of color name -> "R G B" string values. + is_dark: True for dark theme, False for light. + + Returns: + True if successful, False otherwise. + """ + user_reg = pfx / "user.reg" + if not user_reg.is_file(): + return False + + try: + content = user_reg.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + + lines = content.splitlines() + + # Find or create Colors section + colors_idx = _find_or_create_section( + lines, + "Control Panel\\\\Colors" # Escaped backslash for .reg format + ) + + # Apply each color + for key, value in colors.items(): + _set_string_value(lines, colors_idx, key, value) + + # Find or create Personalize section (Windows 10+ dark mode) + personalize_idx = _find_or_create_section( + lines, + "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize" + ) + + # Set dark mode flags: 0 = dark, 1 = light + light_value = 0 if is_dark else 1 + _set_dword_value(lines, personalize_idx, "AppsUseLightTheme", light_value) + _set_dword_value(lines, personalize_idx, "SystemUsesLightTheme", light_value) + + # Find or create DWM section for titlebar colors + dwm_idx = _find_or_create_section( + lines, + "Software\\\\Microsoft\\\\Windows\\\\DWM" + ) + + # Apply DWM settings + dwm_settings = DARK_DWM if is_dark else LIGHT_DWM + for key, value in dwm_settings.items(): + _set_dword_value(lines, dwm_idx, key, value) + + # Find or create Explorer Accent section + explorer_idx = _find_or_create_section( + lines, + "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\Accent" + ) + + # Apply Explorer accent settings + explorer_settings = DARK_EXPLORER if is_dark else LIGHT_EXPLORER + for key, value in explorer_settings.items(): + _set_dword_value(lines, explorer_idx, key, value) + + # Find ThemeManager section and disable msstyles for dark theme + # (Wine visual styles can override our color settings) + theme_mgr_idx = _find_or_create_section( + lines, + "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\ThemeManager" + ) + + if is_dark: + # Disable visual styles to use our dark colors + _set_string_value(lines, theme_mgr_idx, "ThemeActive", "0") + else: + _set_string_value(lines, theme_mgr_idx, "ThemeActive", "1") + + # Write back + try: + user_reg.write_text("\n".join(lines) + "\n", encoding="utf-8") + return True + except OSError: + return False + + +def apply_theme_to_all(steamapps: Path, theme: str) -> tuple[int, int]: + """Apply theme to all Proton prefixes and Vual's own prefix. + + Args: + steamapps: Path to Steam/steamapps directory. + theme: Theme name ("dark", "light", or "system"). + + Returns: + Tuple of (success_count, fail_count). + """ + if theme == "system" or theme not in COLOR_SCHEMES: + return (0, 0) + + colors = COLOR_SCHEMES[theme] + is_dark = (theme == "dark") + success = 0 + failed = 0 + + # Apply to Steam prefixes + for pfx in get_all_prefixes(steamapps): + if apply_colors_to_prefix(pfx, colors, is_dark): + success += 1 + else: + failed += 1 + + # Apply to Vual's own prefix + vual_pfx = get_vual_prefix() + if vual_pfx: + if apply_colors_to_prefix(vual_pfx, colors, is_dark): + success += 1 + else: + failed += 1 + + 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 diff --git a/vual b/vual new file mode 100755 index 0000000..70c082c --- /dev/null +++ b/vual @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Dev launcher for Vual.""" + +import sys +from pathlib import Path + +# All pycache in one place +sys.pycache_prefix = str(Path(__file__).resolve().parent / ".pycache") + +sys.path.insert(0, str(Path(__file__).resolve().parent / "src")) + +from vual.__main__ import main + +main()