diff --git a/README.md b/README.md index 62460a0..9843d88 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ ## О проекте -Vual — приложение для запуска Cheat Engine в Steam-играх, работающих через Proton. -Показывает библиотеку Steam, управляет protonhax и launch options, скачивает и +Vual — приложение для запуска [Cheat Engine](https://github.com/cheat-engine/cheat-engine) в Steam-играх, работающих через Proton. +Показывает библиотеку Steam, управляет [protonhax](https://github.com/jcnils/protonhax) и launch options, скачивает и распаковывает CE автоматически, запускает его в нужном Wine-префиксе — всё из одного окна. @@ -35,6 +35,15 @@ Vual — приложение для запуска Cheat Engine в Steam-игр - **Прозрачность окна** — полупрозрачный фон в стиле frosted glass - **Гайд** — встроенная страница быстрого старта при первом запуске и из меню +## Скриншоты + +

+ Главная + Внешний вид + Steam + Cheat Engine +

+ ## Установка ### Stapler @@ -55,12 +64,12 @@ cd vual
Зависимости -| Дистрибутив | Команда | -|---|---| -| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` | -| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-requests` | -| Arch | `pacman -S python-gobject gtk4 libadwaita python-requests` | -| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 python3-requests` | +| Дистрибутив | Команда | +|------------------|---------------------------------------------------------------------------------| +| ALT Linux | `apt-get install python3-module-pygobject3 libgtk4 libadwaita python3-module-requests` | +| Fedora | `dnf install python3-gobject gtk4 libadwaita python3-requests` | +| Arch | `pacman -S python-gobject gtk4 libadwaita python-requests` | +| Debian / Ubuntu | `apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 python3-requests` | Также нужен **Proton** (через Steam) — для распаковки CE и работы protonhax. @@ -68,63 +77,62 @@ cd vual ## Использование -``` -./vual -``` +### Перед началом -1. При первом запуске откроется встроенный гайд -2. Перейди в **Настройки → Cheat Engine** — скачай и распакуй CE -3. Установи **protonhax** там же -4. Включи переключатель у нужной игры — Vual пропишет launch options -5. Нажми ▶ — игра запустится, CE откроется автоматически +- Установи или скачай Cheat Engine в настройках +- Проверь, что путь к Steam указывает на правильную библиотеку +- Закрой Steam перед массовым изменением launch options + +### Как работает Vual + +| Шаг | | | +|---|---|---| +| **1** | **Выбери игру** | Найди игру в библиотеке и включи переключатель на её плитке | +| **2** | **Запусти через Steam** | Steam запустит игру с protonhax и подготовит нужное окружение | +| **3** | **Открой Cheat Engine** | Используй меню плитки, чтобы запустить CE для работающей игры | +| **4** | **Привяжи таблицу** | Привяжи .CT-файл один раз — Vual будет передавать его в CE автоматически | + +### Полезно знать + +- Поиск в заголовке быстро фильтрует большие библиотеки +- Кнопка меню на каждой плитке содержит действия CE и привязку таблиц +- Прозрачность окна включается в настройках внешнего вида ## Управление -| | | -|---|---| -| Запуск CE | Клик по кнопке ▶ на плитке игры | -| Поиск | Клик по полю поиска в заголовке | -| Привязать таблицу | Правая кнопка мыши → Assign Table | -| Вкл/выкл protonhax | Переключатель на плитке игры | -| Массовое вкл/выкл | Кнопки Enable All / Disable All | -| Сортировка | Меню сортировки в заголовке | -| Настройки | Главное меню → Preferences | -| Гайд | Главное меню → Guide | +| Действие | Как | +|--------------------|-----------------------------------------| +| Запуск CE | Кнопка ▶ на плитке игры | +| Поиск | Поле поиска в заголовке | +| Привязать таблицу | Меню плитки → Привязать таблицу | +| Вкл/выкл protonhax| Переключатель на плитке | +| Массовое вкл/выкл | Включить все / Отключить все | +| Сортировка | Меню сортировки в заголовке | +| Настройки | ☰ → Настройки | +| Гайд | ☰ → Гайд | ## Настройки -| | | По умолчанию | -|---|---|---| -| Тема | Системная / Светлая / Тёмная | Системная | -| Язык | Авто / English / Русский | Авто | -| Размер плиток | Small / Medium / Large | Medium | -| Сортировка | По имени / По статусу | По имени | -| Прозрачность окна | вкл / выкл | выкл | -| Тема Wine | Тёмная / Светлая | — | -| Язык CE | EN / RU | — | +| Параметр | Варианты | По умолчанию | +|--------------------|------------------------------|--------------| +| Тема | Системная / Светлая / Тёмная | Системная | +| Язык | Авто / Английский / Русский | Авто | +| Размер плиток | Маленький / Средний / Большой| Средний | +| Сортировка | По имени / По статусу | По имени | +| Прозрачность окна | вкл / выкл | выкл | +| Тема Wine | Системная / Тёмная / Светлая | Системная | +| Язык CE | Системный / Русский | Системный | ## Данные -`~/.config/vual/` - -| | | -|---|---| -| config.json | Настройки | - -`~/.local/share/vual/` - -| | | -|---|---| -| cheatengine/ | Cheat Engine | -| bin/protonhax | protonhax | -| tables/ | .CT-таблицы и привязки | -| wine_prefix/ | Wine-префикс для тестового запуска CE | - -`~/.cache/vual/` - -| | | -|---|---| -| covers/ | Кэш обложек Steam | +| Путь | Содержимое | +|-----------------------------------|-------------------------------------| +| `~/.config/vual/config.json` | Настройки | +| `~/.local/share/vual/cheatengine/`| Cheat Engine | +| `~/.local/share/vual/bin/protonhax`| protonhax | +| `~/.local/share/vual/tables/` | .CT-таблицы и привязки | +| `~/.local/share/vual/wine_prefix/`| Wine-префикс для тестового запуска | +| `~/.cache/vual/covers/` | Кэш обложек Steam | ## Участие в разработке diff --git a/data/Vual.png b/data/Vual.png index 1faff04..522a5b9 100644 Binary files a/data/Vual.png 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 index f74a814..943ebd9 100644 Binary files a/data/icons/hicolor/128x128/apps/io.github.vual.png 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 index e5c3dfd..3aa689d 100644 Binary files a/data/icons/hicolor/16x16/apps/io.github.vual.png 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 index 1485faa..2d5dcac 100644 Binary files a/data/icons/hicolor/24x24/apps/io.github.vual.png 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 index 6c73813..acb1982 100644 Binary files a/data/icons/hicolor/256x256/apps/io.github.vual.png 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 index 22ff8bf..b3c01de 100644 Binary files a/data/icons/hicolor/32x32/apps/io.github.vual.png 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 index 3ceac1b..9ddbbe5 100644 Binary files a/data/icons/hicolor/48x48/apps/io.github.vual.png 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 index 9680d2b..2ac4471 100644 Binary files a/data/icons/hicolor/512x512/apps/io.github.vual.png 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 index f23ead6..0309a58 100644 Binary files a/data/icons/hicolor/64x64/apps/io.github.vual.png and b/data/icons/hicolor/64x64/apps/io.github.vual.png differ diff --git a/data/io.github.vual.metainfo.xml b/data/io.github.vual.metainfo.xml index 5f2f758..d3f5e4e 100644 --- a/data/io.github.vual.metainfo.xml +++ b/data/io.github.vual.metainfo.xml @@ -64,7 +64,22 @@ Steam game library with covers Библиотека Steam с обложками - https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main.png + https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/main_page.png + + + Appearance settings + Настройки внешнего вида + https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/pref_app.png + + + Steam settings + Настройки Steam + https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/pref_steam.png + + + Cheat Engine settings + Настройки Cheat Engine + https://raw.githubusercontent.com/Cheviiot/vual/main/data/screenshots/pref_ce.png @@ -82,6 +97,12 @@ + + +

Code split into modules, new icon, screenshots, version check before download, full localization sync.

+

Код разбит на модули, новая иконка, скриншоты, проверка версии перед скачиванием, полная синхронизация локализации.

+
+

CE table binding, Wine theme manager, built-in guide, window transparency, Adwyra-inspired theme, Russian CE locale installer.

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