This commit is contained in:
2026-03-19 02:47:08 +10:00
commit 66dffe050c
37 changed files with 5572 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
"""Vual — launch Cheat Engine for Steam games via Proton."""
from pathlib import Path
__version__ = "0.1.0"
APP_ID = "io.github.vual"
APP_NAME = "Vual"
# Paths: try development first, then fall back to installed locations
_PKG_DIR = Path(__file__).parent
_DATA_DIR = _PKG_DIR.parent.parent / "data"
# Icon
_DEV_ICON = _DATA_DIR / "Vual.png"
ICON_PATH = _DEV_ICON if _DEV_ICON.exists() else None
# CSS (in package data)
_PKG_CSS = _PKG_DIR / "data" / "style.css"
CSS_PATH = _PKG_CSS if _PKG_CSS.exists() else None
+57
View File
@@ -0,0 +1,57 @@
"""Entry point for the Vual GUI application."""
import os
import sys
def _ensure_gi_available():
"""Add system site-packages to path if gi is not importable (e.g., in venv)."""
try:
import gi # noqa: F401
return
except ImportError:
pass
candidates = [
"/usr/lib64/python3/site-packages",
"/usr/lib/python3/site-packages",
"/usr/lib/python3/dist-packages",
f"/usr/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages",
]
for path in candidates:
if os.path.isdir(os.path.join(path, "gi")):
sys.path.insert(0, path)
return
print(
"Error: PyGObject (gi) not found.\n"
"Install it with your package manager:\n"
" ALT Linux: sudo apt-get install python3-module-pygobject3\n"
" Fedora: sudo dnf install python3-gobject gtk4 libadwaita\n"
" Ubuntu: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1\n"
" Arch: sudo pacman -S python-gobject gtk4 libadwaita",
file=sys.stderr,
)
sys.exit(1)
def main():
_ensure_gi_available()
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from vual.app import VualApp
app = VualApp()
app.run(sys.argv)
if __name__ == "__main__":
main()
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
"""Adw.Application for Vual."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gio, Gtk # noqa: E402
from vual import APP_ID, APP_NAME, CSS_PATH, ICON_PATH, __version__ # noqa: E402
from vual.config import Config # noqa: E402
from vual.i18n import init as init_i18n # noqa: E402
from vual.window import VualWindow # noqa: E402
class VualApp(Adw.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS)
self.config = Config.load()
init_i18n(self.config.app_language) # Initialize translations with saved language
# ── Activate ─────────────────────────────────────────────────
def do_activate(self) -> None:
# Apply color scheme
style_manager = Adw.StyleManager.get_default()
scheme_map = {
"light": Adw.ColorScheme.FORCE_LIGHT,
"dark": Adw.ColorScheme.FORCE_DARK,
"system": Adw.ColorScheme.DEFAULT,
}
style_manager.set_color_scheme(scheme_map.get(self.config.color_scheme, Adw.ColorScheme.DEFAULT))
win = self.props.active_window
if not win:
win = VualWindow(application=self, config=self.config)
win.set_default_size(self.config.window_width, self.config.window_height)
win.present()
# ── Startup: register actions ────────────────────────────────
def do_startup(self) -> None:
Adw.Application.do_startup(self)
display = Gdk.Display.get_default()
# Load application CSS
if CSS_PATH and display:
css_provider = Gtk.CssProvider()
css_provider.load_from_path(str(CSS_PATH))
Gtk.StyleContext.add_provider_for_display(
display,
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
# Add custom icon path to theme (for development)
if ICON_PATH and display:
Gtk.IconTheme.get_for_display(display).add_search_path(str(ICON_PATH.parent))
about_action = Gio.SimpleAction.new("about", None)
about_action.connect("activate", self._on_about)
self.add_action(about_action)
prefs_action = Gio.SimpleAction.new("preferences", None)
prefs_action.connect("activate", self._on_preferences)
self.add_action(prefs_action)
quit_action = Gio.SimpleAction.new("quit", None)
quit_action.connect("activate", lambda *_: self.quit())
self.add_action(quit_action)
self.set_accels_for_action("app.quit", ["<primary>q"])
# ── About dialog ─────────────────────────────────────────────
def _on_about(self, _action: Gio.SimpleAction, _param: None) -> None:
# Use dev icon name if in dev mode, otherwise use app_id (installed icon)
icon_name = "Vual" if ICON_PATH else APP_ID
about = Adw.AboutDialog(
application_name=APP_NAME,
application_icon=icon_name,
version=__version__,
developer_name="Cheviiot",
website="https://github.com/Cheviiot/vual",
issue_url="https://github.com/Cheviiot/vual/issues",
developers=["Cheviiot"],
copyright="© 2026 Cheviiot",
license_type=Gtk.License.GPL_3_0,
)
about.present(self.props.active_window)
# ── Preferences ──────────────────────────────────────────────
def _on_preferences(self, _action: Gio.SimpleAction, _param: None) -> None:
from vual.ui.preferences import PreferencesWindow
win = PreferencesWindow(config=self.config, transient_for=self.props.active_window)
win.present()
+463
View File
@@ -0,0 +1,463 @@
"""Cheat Engine management: download, extraction, and version detection.
Provides functionality for:
- Fetching latest CE release info from cheatengine.org
- Downloading the installer with progress reporting
- Extracting using Wine from Proton (silent install)
- Detecting CE version from installed files
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
from typing import TYPE_CHECKING
import requests
if TYPE_CHECKING:
from collections.abc import Callable
# Official Cheat Engine download page
CE_DOWNLOADS_URL = "https://cheatengine.org/downloads.php"
# Known Proton versions to search for Wine binary
_PROTON_DIRS = [
"Proton - Experimental",
"Proton 9.0",
"Proton 8.0",
"Proton 7.0",
]
# CE executable names in order of preference
_CE_EXECUTABLES = [
"cheatengine-x86_64.exe",
"cheatengine-i386.exe",
"Cheat Engine.exe",
]
# ════════════════════════════════════════════════════════════════
# Release Info
# ════════════════════════════════════════════════════════════════
def get_latest_release() -> dict[str, str | int | None] | None:
"""Fetch latest Cheat Engine release info from official website.
Scrapes cheatengine.org/downloads.php for the Windows installer link.
Returns:
Dict with keys: version, url, name, size (or None on error).
"""
try:
resp = requests.get(CE_DOWNLOADS_URL, timeout=15)
resp.raise_for_status()
text = resp.text
# Extract version (e.g. "Cheat Engine 7.6")
ver_m = re.search(r"Cheat Engine\s+([\d.]+)", text)
version = ver_m.group(1) if ver_m else "unknown"
# Find the first cloudfront .exe link (Windows installer)
exe_m = re.search(r'href="(https://[^"]*cloudfront[^"]*\.exe)"', text, re.I)
if not exe_m:
return {"version": version, "url": None, "name": None, "size": 0}
url = exe_m.group(1)
name = f"CheatEngine{version.replace('.', '')}.exe"
# Get file size via HEAD request
size = 0
try:
head = requests.head(url, allow_redirects=True, timeout=10)
size = int(head.headers.get("content-length", 0))
except requests.RequestException:
pass
return {"version": version, "url": url, "name": name, "size": size}
except (requests.RequestException, ValueError):
return None
# ════════════════════════════════════════════════════════════════
# Download
# ════════════════════════════════════════════════════════════════
def download_file(
url: str,
dest: Path,
progress_cb: Callable[[float], None] | None = None,
) -> bool:
"""Download a file with optional progress reporting.
Args:
url: URL to download.
dest: Destination path for the file.
progress_cb: Optional callback receiving progress (0.0-1.0).
Returns:
True on success, False on error.
"""
try:
resp = requests.get(url, stream=True, timeout=120)
resp.raise_for_status()
total = int(resp.headers.get("content-length", 0))
downloaded = 0
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
for chunk in resp.iter_content(chunk_size=65536):
f.write(chunk)
downloaded += len(chunk)
if progress_cb and total > 0:
progress_cb(downloaded / total)
return True
except requests.RequestException:
return False
# ════════════════════════════════════════════════════════════════
# Proton Wine Discovery
# ════════════════════════════════════════════════════════════════
def find_proton_wine(steam_path: str = "~/.local/share/Steam") -> Path | None:
"""Find a Wine binary bundled with a Proton installation.
Searches common Proton versions first, then falls back to any
Proton directory in steamapps/common.
Args:
steam_path: Path to Steam installation (can use ~).
Returns:
Path to Wine binary, or None if not found.
"""
steamapps = Path(steam_path).expanduser() / "steamapps" / "common"
if not steamapps.is_dir():
return None
for name in _PROTON_DIRS:
wine = steamapps / name / "files" / "bin" / "wine"
if wine.is_file():
return wine
# Fallback: search any Proton directory
for d in sorted(steamapps.iterdir(), reverse=True):
if d.name.lower().startswith("proton"):
wine = d / "files" / "bin" / "wine"
if wine.is_file():
return wine
return None
# ════════════════════════════════════════════════════════════════
# Extraction
# ════════════════════════════════════════════════════════════════
def can_extract(steam_path: str = "~/.local/share/Steam") -> str | None:
"""Check if extraction is possible.
Args:
steam_path: Path to Steam installation.
Returns:
Name of extraction method ("wine") or None if unavailable.
"""
if find_proton_wine(steam_path):
return "wine"
return None
def extract_installer(
installer: Path,
dest_dir: Path,
steam_path: str = "~/.local/share/Steam",
) -> bool:
"""Extract Cheat Engine installer into destination directory.
Uses Wine from Proton to run the silent installer. The CE installer
is wrapped by zbShield which doesn't exit cleanly in silent mode,
so we poll for completion and kill the process.
Args:
installer: Path to CE installer executable.
dest_dir: Destination directory for extracted files.
steam_path: Path to Steam installation.
Returns:
True on success, False on error.
"""
wine = find_proton_wine(steam_path)
if not wine:
return False
return _extract_via_wine(wine, installer, dest_dir)
def _extract_via_wine(wine: Path, installer: Path, dest_dir: Path) -> bool:
"""Run CE installer silently via Proton Wine and copy results."""
cache_dir = Path.home() / ".cache" / "vual"
cache_dir.mkdir(parents=True, exist_ok=True)
wineserver = wine.parent / "wineserver"
with tempfile.TemporaryDirectory(dir=cache_dir, prefix="ce_install_") as tmpdir:
prefix = Path(tmpdir) / "prefix"
prefix.mkdir()
env = dict(os.environ)
env.update({
"WINEPREFIX": str(prefix),
"WINEDLLOVERRIDES": "mshtml=d",
"WINEDEBUG": "-all",
"PATH": str(wine.parent) + ":" + env.get("PATH", ""),
})
# Initialize Wine prefix
try:
subprocess.run(
[str(wine), "wineboot", "--init"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=120,
env=env,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
pass
# Launch installer as a background process — zbShield wrapper
# never exits cleanly in silent mode, so we poll for results.
proc = subprocess.Popen(
[
str(wine),
str(installer),
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/SP-",
"/NORUN",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
ce_src = prefix / "drive_c" / "Program Files" / "Cheat Engine"
marker = ce_src / "cheatengine-x86_64.exe"
completion = ce_src / "unins000.exe"
# Poll until both the 64-bit CE exe AND the uninstaller appear
# and the total file count stabilises for 3 consecutive seconds.
prev_count = 0
stable = 0
try:
for _ in range(180): # max 3 minutes
time.sleep(1)
if marker.is_file() and completion.is_file():
cur = sum(1 for _ in ce_src.rglob("*"))
if cur == prev_count and cur > 50:
stable += 1
if stable >= 3:
break
else:
stable = 0
prev_count = cur
else:
# Timeout — use whatever we have if any
if not ce_src.is_dir():
return False
finally:
proc.kill()
try:
subprocess.run(
[str(wineserver), "-k"],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=10,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
time.sleep(1)
if not ce_src.is_dir():
return False
# Move installed files to dest_dir
dest_dir.mkdir(parents=True, exist_ok=True)
for item in ce_src.iterdir():
target = dest_dir / item.name
if target.exists():
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
shutil.move(str(item), str(target))
return True
# ════════════════════════════════════════════════════════════════
# Executable Detection
# ════════════════════════════════════════════════════════════════
def find_executable(search_dir: Path) -> Path | None:
"""Find the main CE executable in a directory.
Searches for known CE executable names in order of preference.
Args:
search_dir: Directory to search recursively.
Returns:
Path to CE executable, or None if not found.
"""
# Try known names first
for name in _CE_EXECUTABLES:
for hit in search_dir.rglob(name):
return hit
# Fallback: any cheatengine*.exe
for hit in search_dir.rglob("cheatengine*.exe"):
return hit
return None
def detect_version(ce_path: Path) -> str | None:
"""Detect CE version from file names or paths.
Args:
ce_path: Path to CE installation.
Returns:
Version string (e.g., "7.6") or None if not detected.
"""
# Check installer filename in cache (e.g. CheatEngine76.exe → 7.6)
cache = Path.home() / ".cache" / "vual"
if cache.is_dir():
for f in cache.iterdir():
if f.suffix == ".exe" and "cheatengine" in f.name.lower():
m = re.search(r"(\d)(\d+)", f.stem)
if m:
return f"{m.group(1)}.{m.group(2)}"
# Check parent directory name
for part in ce_path.parts:
m = re.search(r"[Cc]heat.?[Ee]ngine\s*(\d+\.?\d*)", part)
if m:
return m.group(1)
return None
# ════════════════════════════════════════════════════════════════
# Localization
# ════════════════════════════════════════════════════════════════
# Russian localization from official CE repo
_LOCALIZATION_API = (
"https://api.github.com/repos/cheat-engine/cheat-engine/contents/"
"Cheat%20Engine/bin/languages/ru_RU"
)
_LOCALIZATION_RAW = (
"https://raw.githubusercontent.com/cheat-engine/cheat-engine/master/"
"Cheat%20Engine/bin/languages/ru_RU/"
)
_LOCALIZATION_DIR = "ru_RU"
def get_languages_dir(ce_path: Path) -> Path | None:
"""Get the languages directory for CE installation.
Args:
ce_path: Path to CE executable.
Returns:
Path to languages directory, or None if invalid.
"""
if ce_path.is_file():
return ce_path.parent / "languages"
return None
def is_localization_installed(ce_path: Path) -> bool:
"""Check if Russian localization is installed.
Args:
ce_path: Path to CE executable.
Returns:
True if ru_RU directory exists with .po files.
"""
lang_dir = get_languages_dir(ce_path)
if not lang_dir:
return False
ru_dir = lang_dir / _LOCALIZATION_DIR
if not ru_dir.is_dir():
return False
# Check for at least one .po file
return any(ru_dir.glob("*.po"))
def _get_localization_files() -> list[str]:
"""Fetch list of files in ru_RU localization directory from GitHub API."""
try:
resp = requests.get(_LOCALIZATION_API, timeout=15)
resp.raise_for_status()
data = resp.json()
if isinstance(data, list):
return [item["name"] for item in data if item.get("type") == "file"]
except (requests.RequestException, ValueError, KeyError):
pass
return []
def install_localization(
ce_path: Path,
progress_cb: Callable[[float], None] | None = None,
) -> bool:
"""Download and install Russian localization for Cheat Engine.
Downloads ru_RU localization files from official CE repository
and places them in the languages/ru_RU directory.
Args:
ce_path: Path to CE executable.
progress_cb: Optional callback receiving progress (0.0-1.0).
Returns:
True on success, False on error.
"""
lang_dir = get_languages_dir(ce_path)
if not lang_dir:
return False
ru_dir = lang_dir / _LOCALIZATION_DIR
ru_dir.mkdir(parents=True, exist_ok=True)
files = _get_localization_files()
if not files:
return False
total = len(files)
downloaded = 0
for filename in files:
url = _LOCALIZATION_RAW + filename
dest = ru_dir / filename
try:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
dest.write_bytes(resp.content)
downloaded += 1
if progress_cb:
progress_cb(downloaded / total)
except requests.RequestException:
# Continue with other files
pass
# Success if at least the main .po file downloaded
return (ru_dir / "cheatengine-x86_64.po").is_file()
+131
View File
@@ -0,0 +1,131 @@
"""Configuration management for Vual.
Stores settings in ~/.config/vual/config.json
"""
import json
from dataclasses import dataclass, field
from pathlib import Path
_DEFAULT_PROTONHAX = str(Path.home() / ".local" / "share" / "vual" / "bin" / "protonhax")
_DEFAULT_TEMPLATE = f"{_DEFAULT_PROTONHAX} init %COMMAND%"
_DEFAULT_EXCLUDED = ["^Proton", "^Steam Linux Runtime"]
# Config file location
CONFIG_PATH = Path.home() / ".config" / "vual" / "config.json"
# Tile size presets: small=120, medium=150, large=180
TILE_SIZES = {"small": 120, "medium": 150, "large": 180}
@dataclass
class Config:
"""Application configuration.
Attributes:
ce_executable: Path to Cheat Engine executable.
steam_path: Path to Steam installation directory.
lookup_enabled: Whether to look up game info.
launch_options_template: Template for Steam launch options.
excluded_app_patterns: Regex patterns to exclude apps.
window_width: Main window width.
window_height: Main window height.
color_scheme: Color scheme ("system", "light", "dark").
tile_size: Tile size preset ("small", "medium", "large").
sort_by: Sort order ("name", "status").
ce_language: CE language ("system", "ru_RU").
wine_theme: Wine color theme ("system", "dark", "light").
app_language: Application UI language ("system", "en", "ru").
"""
ce_executable: str = "~/.local/share/vual/cheatengine/cheatengine-x86_64.exe"
steam_path: str = "~/.local/share/Steam"
lookup_enabled: bool = True
launch_options_template: str = field(default_factory=lambda: _DEFAULT_TEMPLATE)
excluded_app_patterns: list[str] = field(default_factory=lambda: _DEFAULT_EXCLUDED.copy())
window_width: int = 1000
window_height: int = 700
color_scheme: str = "system"
tile_size: str = "medium"
sort_by: str = "name"
ce_language: str = "system"
wine_theme: str = "system"
app_language: str = "system"
# ════════════════════════════════════════════════════════════════
# Load / Save
# ════════════════════════════════════════════════════════════════
@classmethod
def load(cls) -> "Config":
"""Load configuration from JSON file.
Returns:
Config instance with loaded or default values.
"""
if CONFIG_PATH.exists():
try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return cls(
ce_executable=data.get("ce_executable", cls.ce_executable),
steam_path=data.get("steam_path", cls.steam_path),
lookup_enabled=data.get("lookup_enabled", cls.lookup_enabled),
launch_options_template=data.get("launch_options_template", _DEFAULT_TEMPLATE),
excluded_app_patterns=data.get("excluded_app_patterns", _DEFAULT_EXCLUDED.copy()),
window_width=data.get("window_width", cls.window_width),
window_height=data.get("window_height", cls.window_height),
color_scheme=data.get("color_scheme", cls.color_scheme),
tile_size=data.get("tile_size", cls.tile_size),
sort_by=data.get("sort_by", cls.sort_by),
ce_language=data.get("ce_language", cls.ce_language),
wine_theme=data.get("wine_theme", cls.wine_theme),
app_language=data.get("app_language", cls.app_language),
)
except (json.JSONDecodeError, OSError):
pass
return cls()
def save(self) -> Path:
"""Save configuration to JSON file.
Returns:
Path to the saved config file.
"""
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
data = {
"ce_executable": self.ce_executable,
"steam_path": self.steam_path,
"lookup_enabled": self.lookup_enabled,
"launch_options_template": self.launch_options_template,
"excluded_app_patterns": self.excluded_app_patterns,
"window_width": self.window_width,
"window_height": self.window_height,
"color_scheme": self.color_scheme,
"tile_size": self.tile_size,
"sort_by": self.sort_by,
"ce_language": self.ce_language,
"wine_theme": self.wine_theme,
"app_language": self.app_language,
}
CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return CONFIG_PATH
# ════════════════════════════════════════════════════════════════
# Derived Paths
# ════════════════════════════════════════════════════════════════
@property
def steamapps_path(self) -> Path:
"""Path to Steam's steamapps directory."""
return Path(self.steam_path).expanduser() / "steamapps"
@property
def ce_executable_path(self) -> Path:
"""Expanded path to CE executable."""
return Path(self.ce_executable).expanduser()
@property
def ce_exists(self) -> bool:
"""Check if CE executable exists."""
return self.ce_executable_path.is_file()
+195
View File
@@ -0,0 +1,195 @@
/* ═══════════════════════════════════════════════════════════════════════════
Vual — Unified Size System
Base unit: 4px
All sizes are multiples of 4px for consistency
═══════════════════════════════════════════════════════════════════════════ */
/* ═══════════════════════════════════════════════════════════════════════════
HEADER BAR
═══════════════════════════════════════════════════════════════════════════ */
/* Header buttons: 32x32 (8 units) */
.header-btn {
min-width: 32px;
min-height: 32px;
padding: 0;
margin: 0;
border-radius: 6px;
}
.header-btn:hover {
background: alpha(@accent_bg_color, 0.12);
}
.header-btn:active {
background: alpha(@accent_bg_color, 0.2);
}
/* Games counter */
.games-counter {
font-size: 0.85em;
font-weight: 500;
opacity: 0.6;
margin-left: 8px;
}
/* Search entry */
searchentry {
min-width: 220px;
}
/* ═══════════════════════════════════════════════════════════════════════════
GRID LAYOUT
═══════════════════════════════════════════════════════════════════════════ */
.vual-grid {
/* Padding handled in Python via set_margin_* */
}
flowboxchild {
padding: 0;
margin: 0;
background: transparent;
outline: none;
border: none;
box-shadow: none;
}
flowboxchild:focus,
flowboxchild:hover,
flowboxchild:selected {
background: transparent;
outline: none;
box-shadow: none;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE
═══════════════════════════════════════════════════════════════════════════ */
/* Tile container: 12px radius */
.vual-tile {
border-radius: 12px;
background: @card_bg_color;
box-shadow: 0 1px 2px alpha(black, 0.08);
}
.vual-tile:hover {
box-shadow: 0 2px 8px alpha(black, 0.15);
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE INFO OVERLAY (bottom)
═══════════════════════════════════════════════════════════════════════════ */
/* Info panel: gradient background */
.vual-tile-info {
padding: 24px 8px 8px 8px;
background: linear-gradient(to bottom,
transparent 0%,
alpha(black, 0.6) 50%,
alpha(black, 0.8) 100%);
border-radius: 0 0 12px 12px;
}
.vual-tile-info label {
color: white;
}
.vual-tile-info .heading {
font-weight: 600;
font-size: 0.85em;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE BUTTONS (play, CE)
═══════════════════════════════════════════════════════════════════════════ */
/* Tile buttons: 28x28 (7 units) */
.tile-btn,
.vual-tile-info button {
min-width: 28px;
min-height: 28px;
padding: 0;
margin: 0;
color: white;
background: alpha(white, 0.12);
border-radius: 6px;
border: none;
}
.tile-btn:hover,
.vual-tile-info button:hover {
background: alpha(white, 0.24);
}
.tile-btn:active,
.vual-tile-info button:active {
background: alpha(white, 0.32);
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE SWITCH
═══════════════════════════════════════════════════════════════════════════ */
.vual-tile-info switch {
background: alpha(white, 0.15);
border: none;
}
.vual-tile-info switch:checked {
background: @accent_bg_color;
}
.vual-tile-info switch slider {
background: white;
min-width: 16px;
min-height: 16px;
border-radius: 8px;
}
/* ═══════════════════════════════════════════════════════════════════════════
RELOAD BUTTON (top-left corner)
═══════════════════════════════════════════════════════════════════════════ */
/* Reload: 24x24 (6 units) */
.vual-reload-btn {
min-width: 24px;
min-height: 24px;
padding: 0;
margin: 0;
border-radius: 6px;
background: alpha(black, 0.5);
color: white;
opacity: 0;
border: none;
}
.vual-tile:hover .vual-reload-btn {
opacity: 1;
}
.vual-reload-btn:hover {
background: @accent_bg_color;
}
/* ═══════════════════════════════════════════════════════════════════════════
RUNNING BADGE (top-right corner)
═══════════════════════════════════════════════════════════════════════════ */
/* Badge: 20px total (14px icon + 3px padding each side) */
.vual-badge {
background: @success_bg_color;
border-radius: 50%;
padding: 3px;
}
/* ═══════════════════════════════════════════════════════════════════════════
SKELETON (loading placeholder)
═══════════════════════════════════════════════════════════════════════════ */
.skeleton {
background: alpha(@card_shade_color, 0.2);
border-radius: 12px;
}
+112
View File
@@ -0,0 +1,112 @@
"""Internationalization support for Vual.
Provides gettext-based translation functions.
"""
from __future__ import annotations
import gettext
import locale
import os
from pathlib import Path
# Application domain
DOMAIN = "vual"
# Locale directories to search (in order)
_PKG_DIR = Path(__file__).parent
_LOCALE_DIRS = [
_PKG_DIR / "locale", # In-package (installed/compiled)
Path("/usr/share/locale"), # System-wide
Path("/usr/local/share/locale"), # Local install
]
# Global translator
_translator: gettext.GNUTranslations | gettext.NullTranslations | None = None
_current_lang: str = "system"
def _find_locale_dir() -> Path | None:
"""Find the first existing locale directory."""
for d in _LOCALE_DIRS:
if d.is_dir():
return d
return None
def _get_system_lang() -> str:
"""Get language code from system locale."""
lang = os.environ.get("LANGUAGE") or os.environ.get("LANG", "en_US.UTF-8")
return lang.split(".")[0].split("_")[0] # e.g., "ru" from "ru_RU.UTF-8"
def init(lang: str = "system") -> None:
"""Initialize translations.
Args:
lang: Language code ("system", "en", "ru").
"system" uses system locale.
"""
global _translator, _current_lang
_current_lang = lang
# Get system locale
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error:
pass
# Determine language to use
if lang == "system":
lang_code = _get_system_lang()
else:
lang_code = lang
locale_dir = _find_locale_dir()
if locale_dir:
try:
_translator = gettext.translation(
DOMAIN,
localedir=str(locale_dir),
languages=[lang_code],
fallback=True,
)
except OSError:
_translator = gettext.NullTranslations()
else:
_translator = gettext.NullTranslations()
def set_language(lang: str) -> None:
"""Change application language.
Args:
lang: Language code ("system", "en", "ru").
"""
init(lang)
def get_current_language() -> str:
"""Get current language setting."""
return _current_lang
def _(message: str) -> str:
"""Translate a message string."""
global _translator
if _translator is None:
init()
return _translator.gettext(message) if _translator else message
def ngettext(singular: str, plural: str, n: int) -> str:
"""Translate a plural message."""
global _translator
if _translator is None:
init()
return _translator.ngettext(singular, plural, n) if _translator else (singular if n == 1 else plural)
# Initialize on import
init()
Binary file not shown.
Binary file not shown.
+235
View File
@@ -0,0 +1,235 @@
"""Integrated protonhax — list & run Proton games, manage init script."""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import subprocess
from pathlib import Path
# ── Managed paths ────────────────────────────────────────────────
MANAGED_DIR = Path.home() / ".local" / "share" / "vual" / "bin"
MANAGED_PATH = MANAGED_DIR / "protonhax"
def _runtime_dir() -> Path:
xdg = os.environ.get("XDG_RUNTIME_DIR")
if xdg:
return Path(xdg) / "protonhax"
return Path(f"/run/user/{os.getuid()}") / "protonhax"
# ── List running games ──────────────────────────────────────────
def list_running() -> list[str]:
"""Return app IDs of currently running Proton games."""
phd = _runtime_dir()
if not phd.is_dir():
return []
return sorted(d.name for d in phd.iterdir() if d.is_dir() and d.name.isdigit())
# ── Game context ────────────────────────────────────────────────
_ENV_RE = re.compile(r'^declare\s+-x\s+(\w+)="(.*)"$')
def _parse_bash_env(text: str) -> dict[str, str]:
env: dict[str, str] = {}
for line in text.splitlines():
m = _ENV_RE.match(line)
if m:
env[m.group(1)] = m.group(2)
return env
def get_game_context(app_id: str) -> dict | None:
"""Return {app_id, proton_exe, prefix, env} for a running game, or None."""
game_dir = _runtime_dir() / app_id
if not game_dir.is_dir():
return None
ctx: dict = {"app_id": app_id}
exe_file = game_dir / "exe"
if exe_file.exists():
ctx["proton_exe"] = exe_file.read_text().strip()
pfx_file = game_dir / "pfx"
if pfx_file.exists():
ctx["prefix"] = pfx_file.read_text().strip()
env_file = game_dir / "env"
if env_file.exists():
ctx["env"] = _parse_bash_env(env_file.read_text())
return ctx
# ── Run in Proton context ───────────────────────────────────────
def run_in_proton(
app_id: str,
executable: str,
args: list[str] | None = None,
) -> subprocess.Popen | None:
"""Launch *executable* inside the Proton context of *app_id*.
Args:
app_id: Steam app ID.
executable: Path to Windows executable.
args: Optional list of arguments to pass to the executable.
"""
ctx = get_game_context(app_id)
if not ctx or "proton_exe" not in ctx:
return None
env = os.environ.copy()
if "env" in ctx:
env.update(ctx["env"])
proton_exe = ctx["proton_exe"]
cmd = [proton_exe, "run", executable]
if args:
cmd.extend(args)
return subprocess.Popen(
cmd,
env=env,
start_new_session=True,
)
# ── Protonhax detection & management ────────────────────────────
def find_installed() -> str | None:
"""Return the path to protonhax (managed first, then system), or None."""
if MANAGED_PATH.is_file():
return str(MANAGED_PATH)
return shutil.which("protonhax")
def is_managed() -> bool:
"""True if the managed copy exists."""
return MANAGED_PATH.is_file()
def _script_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def needs_update() -> bool:
"""True if the managed copy exists but differs from the bundled version."""
if not MANAGED_PATH.is_file():
return False
try:
installed = MANAGED_PATH.read_text()
except OSError:
return True
return _script_hash(installed) != _script_hash(INIT_SCRIPT)
def ensure_installed() -> Path:
"""Install the managed protonhax if missing or outdated. Returns its path."""
if not MANAGED_PATH.is_file() or needs_update():
return install_init_script(MANAGED_PATH)
return MANAGED_PATH
# ── Bundled init script ─────────────────────────────────────────
INIT_SCRIPT = r"""#!/bin/bash
# protonhax — managed by Vual
# https://github.com/jcnils/protonhax (original)
phd=${XDG_RUNTIME_DIR:-/run/user/$UID}/protonhax
usage() {
echo "Usage:"
echo "protonhax init <cmd>"
printf "\tShould only be called by Steam with \"protonhax init %%COMMAND%%\"\n"
echo "protonhax ls"
printf "\tLists all currently running games\n"
echo "protonhax run <appid> <cmd>"
printf "\tRuns <cmd> in the context of <appid> with proton\n"
echo "protonhax cmd <appid>"
printf "\tRuns cmd.exe in the context of <appid>\n"
echo "protonhax exec <appid> <cmd>"
printf "\tRuns <cmd> in the context of <appid>\n"
}
if [[ $# -lt 1 ]]; then
usage
exit 1
fi
c=$1
shift
if [[ "$c" == "init" ]]; then
mkdir -p "$phd/$SteamAppId"
printf "%s\n" "${@}" | grep -m 1 "/proton" > "$phd/$SteamAppId/exe"
printf "%s" "$STEAM_COMPAT_DATA_PATH/pfx" > "$phd/$SteamAppId/pfx"
declare -px > "$phd/$SteamAppId/env"
"$@"
ec=$?
rm -r "$phd/$SteamAppId"
exit $ec
elif [[ "$c" == "ls" ]]; then
if [[ -d "$phd" ]]; then
ls -1 "$phd"
fi
elif [[ "$c" == "run" ]] || [[ "$c" == "cmd" ]] || [[ "$c" == "exec" ]]; then
if [[ $# -lt 1 ]]; then
usage
exit 1
fi
if [[ ! -d "$phd" ]]; then
printf "No app running with appid \"%s\"\n" "$1"
exit 2
fi
if [[ ! -d "$phd/$1" ]]; then
printf "No app running with appid \"%s\"\n" "$1"
exit 2
fi
SteamAppId=$1
shift
source "$phd/$SteamAppId/env"
if [[ "$c" == "run" ]]; then
if [[ $# -lt 1 ]]; then
usage
exit 1
fi
exec "$(cat "$phd/$SteamAppId/exe")" run "$@"
elif [[ "$c" == "cmd" ]]; then
exec "$(cat "$phd/$SteamAppId/exe")" run "$(cat "$phd/$SteamAppId/pfx")/drive_c/windows/system32/cmd.exe"
elif [[ "$c" == "exec" ]]; then
if [[ $# -lt 1 ]]; then
usage
exit 1
fi
exec "$@"
fi
else
printf "Unknown command %s\n" "$c"
usage
exit 1
fi
"""
def install_init_script(target: Path | None = None) -> Path:
"""Write the protonhax shell script to *target* and make it executable."""
if target is None:
target = MANAGED_PATH
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(INIT_SCRIPT)
target.chmod(0o755)
return target
+538
View File
@@ -0,0 +1,538 @@
"""Steam integration: VDF parsing, game discovery, and LaunchOptions management.
Core functionality:
- VDF (Valve Data File) parsing and writing
- Steam game discovery from appmanifest files
- LaunchOptions manipulation in localconfig.vdf
- Steam process control (detection, shutdown, restart)
"""
from __future__ import annotations
import re
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
# ── VDF parser / writer ──────────────────────────────────────────
def parse_vdf(content: str) -> dict[str, object]:
"""Parse Valve VDF text format into nested dictionaries.
VDF is a key-value format used by Valve in Steam configuration files.
Supports nested sections (dictionaries) and string values.
Args:
content: Raw VDF text content.
Returns:
Nested dict structure representing the VDF data.
Example VDF:
"UserLocalConfigStore"
{
"Software"
{
"Valve" "SomeValue"
}
}
"""
def tokenize(text: str) -> list[tuple[str, str]]:
tokens: list[tuple[str, str]] = []
i = 0
while i < len(text):
if text[i].isspace():
i += 1
elif text[i : i + 2] == "//":
while i < len(text) and text[i] != "\n":
i += 1
elif text[i] == '"':
i += 1
start = i
while i < len(text) and text[i] != '"':
i += 1
tokens.append(("S", text[start:i]))
i += 1
elif text[i] in "{}":
tokens.append(("B", text[i]))
i += 1
else:
i += 1
return tokens
def parse_tokens(tokens: list, idx: int = 0) -> tuple[dict, int]:
result: dict = {}
while idx < len(tokens):
tt, tv = tokens[idx]
if tt == "S":
key = tv
idx += 1
if idx >= len(tokens):
break
nt, nv = tokens[idx]
if nt == "B" and nv == "{":
idx += 1
nested, idx = parse_tokens(tokens, idx)
result[key] = nested
elif nt == "S":
result[key] = nv
idx += 1
elif nt == "B" and nv == "}":
break
elif tt == "B" and tv == "}":
idx += 1
break
else:
idx += 1
return result, idx
tokens = tokenize(content)
parsed, _ = parse_tokens(tokens)
return parsed
def write_vdf(data: dict[str, object], indent: int = 0) -> str:
"""Serialize nested dictionaries into Valve VDF text format.
Args:
data: Dictionary to serialize.
indent: Current indentation level (used internally for recursion).
Returns:
VDF-formatted string.
"""
lines: list[str] = []
tab = "\t" * indent
for key, value in data.items():
if isinstance(value, dict):
lines.append(f'{tab}"{key}"')
lines.append(f"{tab}{{")
lines.append(write_vdf(value, indent + 1).rstrip())
lines.append(f"{tab}}}")
else:
lines.append(f'{tab}"{key}"\t\t"{value}"')
return "\n".join(lines) + "\n"
# ════════════════════════════════════════════════════════════════
# Config Discovery
# ════════════════════════════════════════════════════════════════
def find_localconfig_vdf(steam_path: str) -> Path | None:
"""Find the first localconfig.vdf file in Steam's userdata directory.
This file contains per-user Steam settings including LaunchOptions
for each game.
Args:
steam_path: Path to Steam installation directory (can use ~).
Returns:
Path to localconfig.vdf if found, None otherwise.
"""
userdata = Path(steam_path).expanduser() / "userdata"
if not userdata.exists():
return None
for cfg in userdata.rglob("localconfig.vdf"):
return cfg
return None
# ════════════════════════════════════════════════════════════════
# Game Manifest Reading
# ════════════════════════════════════════════════════════════════
# Cache for compiled regex patterns to avoid recompilation
_EXCLUDE_PATTERNS_CACHE: dict[tuple[str, ...], list[re.Pattern[str]]] = {}
def _get_compiled_patterns(patterns: Sequence[str] | None) -> list[re.Pattern[str]]:
"""Compile and cache exclusion regex patterns."""
if not patterns:
return []
key = tuple(patterns)
if key not in _EXCLUDE_PATTERNS_CACHE:
_EXCLUDE_PATTERNS_CACHE[key] = [re.compile(p, re.IGNORECASE) for p in patterns]
return _EXCLUDE_PATTERNS_CACHE[key]
def is_app_excluded(app_name: str, patterns: Sequence[str] | None) -> bool:
"""Check if app name matches any exclusion pattern.
Args:
app_name: Name of the Steam app.
patterns: List of regex patterns to check against.
Returns:
True if app should be excluded.
"""
return any(pat.search(app_name) for pat in _get_compiled_patterns(patterns))
def get_game_info(app_id: str, steamapps: Path) -> dict[str, str]:
"""Read game information from appmanifest_<id>.acf.
Args:
app_id: Steam app ID.
steamapps: Path to steamapps directory.
Returns:
Dict with keys: app_id, name, install_dir.
"""
info: dict[str, str] = {"app_id": app_id, "name": f"Game {app_id}", "install_dir": ""}
manifest = steamapps / f"appmanifest_{app_id}.acf"
if not manifest.exists():
return info
try:
text = manifest.read_text()
except OSError:
return info
for line in text.splitlines():
line = line.strip()
if line.startswith('"name"'):
parts = line.split('"', 3)
if len(parts) >= 4:
info["name"] = parts[3].rstrip('"')
elif line.startswith('"installdir"'):
parts = line.split('"', 3)
if len(parts) >= 4:
info["install_dir"] = parts[3].rstrip('"')
return info
def get_installed_games(
steamapps: Path,
excluded_patterns: Sequence[str] | None = None,
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Get lists of installed Steam games.
Args:
steamapps: Path to steamapps directory.
excluded_patterns: Regex patterns for apps to exclude.
Returns:
Tuple of (included_games, excluded_games), sorted by name.
"""
included: list[dict[str, str]] = []
excluded: list[dict[str, str]] = []
if not steamapps.exists():
return included, excluded
for mf in steamapps.glob("appmanifest_*.acf"):
aid = mf.stem.replace("appmanifest_", "")
if not aid.isdigit():
continue
info = get_game_info(aid, steamapps)
if is_app_excluded(info["name"], excluded_patterns):
excluded.append(info)
else:
included.append(info)
included.sort(key=lambda g: g["name"].lower())
excluded.sort(key=lambda g: g["name"].lower())
return included, excluded
# ════════════════════════════════════════════════════════════════
# LaunchOptions Management
# ════════════════════════════════════════════════════════════════
# VDF path to the apps section
_VDF_APP_PATH = ["UserLocalConfigStore", "Software", "Valve", "Steam", "apps"]
def _navigate_to_apps(data: dict[str, object]) -> dict[str, object] | None:
"""Navigate parsed VDF dict to the apps section.
Path: UserLocalConfigStore -> Software -> Valve -> Steam -> apps
"""
try:
root = data.get("UserLocalConfigStore", data)
return root.get("Software", {}).get("Valve", {}).get("Steam", {}).get("apps", {})
except (TypeError, AttributeError):
return None
def _navigate_vdf_lines(lines: list[str], path: list[str]) -> tuple[int, int]:
"""Navigate VDF text lines to find a section by key path.
Args:
lines: VDF file content split by lines.
path: List of section keys to navigate.
Returns:
Tuple of (brace_line_index, brace_depth) for the opening brace
of the target section, or (-1, -1) if not found.
"""
target_idx = 0
brace_depth = 0
pending_key: str | None = None
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith("//"):
continue
if stripped == "{":
brace_depth += 1
if (
pending_key is not None
and target_idx < len(path)
and pending_key == path[target_idx]
and brace_depth == target_idx + 1
):
target_idx += 1
if target_idx == len(path):
return i, brace_depth
pending_key = None
continue
if stripped == "}":
brace_depth -= 1
pending_key = None
continue
m = re.match(r'^"([^"]*)"', stripped)
if not m:
pending_key = None
continue
rest = stripped[m.end() :].strip()
if rest:
# key-value pair on one line
pending_key = None
else:
# section header — next line should be "{"
pending_key = m.group(1)
return -1, -1
def get_launch_options(app_id: str, localconfig_path: Path) -> str | None:
"""Return LaunchOptions string for app_id, or None if not set."""
try:
data = parse_vdf(localconfig_path.read_text())
except OSError:
return None
apps = _navigate_to_apps(data)
if not apps:
return None
section = apps.get(app_id) if isinstance(apps, dict) else None
if isinstance(section, dict):
return section.get("LaunchOptions")
return None
def set_launch_options(app_id: str, value: str, localconfig_path: Path) -> bool:
"""Set LaunchOptions for *app_id* via surgical text edit (no full reserialization)."""
try:
text = localconfig_path.read_text()
except OSError:
return False
old = get_launch_options(app_id, localconfig_path)
if old and old != value:
_create_backup(app_id, old)
lines = text.splitlines(keepends=True)
app_path = _VDF_APP_PATH + [app_id]
brace_idx, brace_depth = _navigate_vdf_lines(lines, app_path)
if brace_idx >= 0:
# App section exists — find LaunchOptions or closing '}'
launch_idx = -1
close_idx = -1
depth = brace_depth
for i in range(brace_idx + 1, len(lines)):
stripped = lines[i].strip()
if not stripped or stripped.startswith("//"):
continue
if stripped == "{":
depth += 1
continue
if stripped == "}":
if depth == brace_depth:
close_idx = i
break
depth -= 1
continue
if depth == brace_depth:
km = re.match(r'^"([^"]*)"', stripped)
if km and km.group(1) == "LaunchOptions":
launch_idx = i
break
if launch_idx >= 0:
old_line = lines[launch_idx]
indent = old_line[: len(old_line) - len(old_line.lstrip())]
lines[launch_idx] = f'{indent}"LaunchOptions"\t\t"{value}"\n'
elif close_idx >= 0:
cl = lines[close_idx]
indent = cl[: len(cl) - len(cl.lstrip())] + "\t"
lines.insert(close_idx, f'{indent}"LaunchOptions"\t\t"{value}"\n')
else:
return False
else:
# App section missing — create it inside "apps"
apps_idx, apps_depth = _navigate_vdf_lines(lines, _VDF_APP_PATH)
if apps_idx < 0:
return False
ai = lines[apps_idx][: len(lines[apps_idx]) - len(lines[apps_idx].lstrip())]
ki = ai + "\t"
vi = ki + "\t"
block = [
f'{ki}"{app_id}"\n',
f"{ki}{{\n",
f'{vi}"LaunchOptions"\t\t"{value}"\n',
f"{ki}}}\n",
]
for j, nl in enumerate(block):
lines.insert(apps_idx + 1 + j, nl)
try:
localconfig_path.write_text("".join(lines))
return True
except OSError:
return False
def remove_launch_options(app_id: str, localconfig_path: Path) -> bool:
"""Remove LaunchOptions for *app_id* via surgical text edit."""
try:
text = localconfig_path.read_text()
except OSError:
return False
old = get_launch_options(app_id, localconfig_path)
if not old:
return False
_create_backup(app_id, old)
lines = text.splitlines(keepends=True)
app_path = _VDF_APP_PATH + [app_id]
brace_idx, brace_depth = _navigate_vdf_lines(lines, app_path)
if brace_idx < 0:
return False
depth = brace_depth
for i in range(brace_idx + 1, len(lines)):
stripped = lines[i].strip()
if not stripped or stripped.startswith("//"):
continue
if stripped == "{":
depth += 1
continue
if stripped == "}":
if depth == brace_depth:
break
depth -= 1
continue
if depth == brace_depth:
km = re.match(r'^"([^"]*)"', stripped)
if km and km.group(1) == "LaunchOptions":
del lines[i]
try:
localconfig_path.write_text("".join(lines))
return True
except OSError:
return False
return False
def _create_backup(game_id: str, original: str) -> None:
"""Create a backup file with the original LaunchOptions value."""
backup_dir = Path.home() / ".local" / "share" / "vual" / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
backup = backup_dir / f"launch_options_backup_{ts}.md"
line = f"| {game_id} | {original} |\n"
if backup.exists():
with open(backup, "a") as f:
f.write(line)
else:
with open(backup, "w") as f:
f.write("# Launch Options Backup\n\n")
f.write(f"Generated: {datetime.now().isoformat()}\n\n")
f.write("| Game ID | Original LaunchOptions |\n")
f.write("|---------|------------------------|\n")
f.write(line)
# ════════════════════════════════════════════════════════════════
# Steam Process Control
# ════════════════════════════════════════════════════════════════
def is_steam_running() -> bool:
"""Check if the Steam client process is currently running.
Uses /proc filesystem to detect Steam process.
Excludes steamwebhelper processes.
Returns:
True if Steam main process is running.
"""
proc = Path("/proc")
if not proc.is_dir():
return False
for pid_dir in proc.iterdir():
if not pid_dir.name.isdigit():
continue
try:
cmdline = (pid_dir / "cmdline").read_bytes()
except OSError:
continue
first_arg = cmdline.split(b"\x00", 1)[0]
if first_arg.endswith(b"/steam") and b"steamwebhelper" not in cmdline:
return True
return False
def shutdown_steam() -> None:
"""Request a graceful Steam shutdown via CLI command."""
subprocess.Popen(
["steam", "-shutdown"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def wait_steam_exit(timeout: float = 30.0) -> bool:
"""Wait for Steam to exit.
Args:
timeout: Maximum time to wait in seconds.
Returns:
True if Steam exited, False if timeout reached.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not is_steam_running():
return True
time.sleep(0.5)
return False
def start_steam() -> None:
"""Launch Steam in the background."""
subprocess.Popen(
["steam"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
+1
View File
@@ -0,0 +1 @@
"""Vual UI sub-package."""
+816
View File
@@ -0,0 +1,816 @@
"""Main games grid page.
Clean rewrite using Gtk.FlowBox + simple Gtk.Overlay tiles.
"""
from __future__ import annotations
import subprocess
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Pango", "1.0")
from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk, Pango
import requests
from vual import protonhax, steam
from vual.config import TILE_SIZES, Config
from vual.i18n import _
if TYPE_CHECKING:
from vual.window import VualWindow
# ═══════════════════════════════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════════════════════════════
COVER_RATIO = 1.5 # Height = Width * 1.5 (portrait)
GRID_SPACING = 12 # Spacing between tiles
GRID_MARGIN = 16 # Margin around grid
# Steam cover URLs
COVER_URL = "https://steamcdn-a.akamaihd.net/steam/apps/{}/library_600x900_2x.jpg"
COVER_FALLBACK = "https://steamcdn-a.akamaihd.net/steam/apps/{}/header.jpg"
# Cache directory
CACHE_DIR = Path(GLib.get_user_cache_dir()) / "vual" / "covers"
# ═══════════════════════════════════════════════════════════════════════════════
# Cover loading utilities
# ═══════════════════════════════════════════════════════════════════════════════
def _cache_path(app_id: str) -> Path:
"""Get cache path for cover image."""
return CACHE_DIR / f"{app_id}.jpg"
def _download_cover(app_id: str) -> Path | None:
"""Download cover from Steam CDN. Returns cache path or None."""
cache = _cache_path(app_id)
if cache.exists():
return cache
CACHE_DIR.mkdir(parents=True, exist_ok=True)
for url_template in (COVER_URL, COVER_FALLBACK):
url = url_template.format(app_id)
try:
resp = requests.get(url, headers={"User-Agent": "Vual/1.0"}, timeout=10)
if resp.status_code == 200:
cache.write_bytes(resp.content)
return cache
except requests.RequestException:
continue
return None
def _load_pixbuf(path: Path, width: int, height: int) -> GdkPixbuf.Pixbuf | None:
"""Load and scale pixbuf from file.
Handles landscape images by compositing them onto a blurred background.
"""
try:
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
except GLib.Error:
return None
ow, oh = original.get_width(), original.get_height()
if ow < 1 or oh < 1:
return None
aspect = ow / oh
# If image is landscape (wider than 4:3), create composite with blur
if aspect > 1.33:
return _create_landscape_composite(original, ow, oh, width, height)
# Portrait or square: scale to fill
return original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
def _create_landscape_composite(
original: GdkPixbuf.Pixbuf,
ow: int, oh: int,
width: int, height: int
) -> GdkPixbuf.Pixbuf:
"""Create a portrait tile from a landscape image.
Composites the original image centered on a scaled/blurred background.
"""
# Create background: scale original to fill tile (will be cropped/stretched)
bg = original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
# Apply simple darkening to background (simulates blur effect)
# We darken by compositing with a semi-transparent black
dark = GdkPixbuf.Pixbuf.new(
GdkPixbuf.Colorspace.RGB, True, 8, width, height
)
dark.fill(0x00000099) # Black with ~60% opacity
dark.composite(
bg, 0, 0, width, height,
0, 0, 1.0, 1.0,
GdkPixbuf.InterpType.NEAREST, 180
)
# Scale original to fit width while preserving aspect ratio
scale = width / ow
scaled_w = width
scaled_h = int(oh * scale)
scaled = original.scale_simple(scaled_w, scaled_h, GdkPixbuf.InterpType.BILINEAR)
# Center vertically
y_offset = (height - scaled_h) // 2
# Composite scaled image onto darkened background
scaled.composite(
bg,
0, y_offset, # dest x, y
scaled_w, scaled_h, # dest width, height
0, y_offset, # offset x, y
1.0, 1.0, # scale x, y
GdkPixbuf.InterpType.BILINEAR,
255 # full opacity
)
return bg
# ═══════════════════════════════════════════════════════════════════════════════
# GameTile — single game tile widget
# ═══════════════════════════════════════════════════════════════════════════════
class GameTile(Gtk.Overlay):
"""A single game tile with cover image and controls overlay."""
__gtype_name__ = "VualGameTile"
def __init__(self, app_id: str, name: str, is_active: bool, tile_width: int):
super().__init__()
self._app_id = app_id
self._name = name
self._is_active = is_active
self._is_running = False
self._width = tile_width
self._height = int(tile_width * COVER_RATIO)
# Fixed size container
self.set_size_request(self._width, self._height)
self.add_css_class("vual-tile")
self.set_overflow(Gtk.Overflow.HIDDEN)
# Cover image (fills the tile)
self._picture = Gtk.Picture()
self._picture.set_content_fit(Gtk.ContentFit.COVER)
self._picture.set_size_request(self._width, self._height)
self._picture.add_css_class("skeleton")
self.set_child(self._picture)
# Bottom info overlay
self._build_info_box()
# Top-left reload button
self._build_reload_button()
# Top-right running badge
self._build_running_badge()
def _build_info_box(self) -> None:
"""Create bottom info panel with name, buttons, and switch."""
info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
info.add_css_class("vual-tile-info")
info.set_valign(Gtk.Align.END)
info.set_halign(Gtk.Align.FILL)
# Game name
label = Gtk.Label(label=self._name)
label.set_ellipsize(Pango.EllipsizeMode.END)
label.set_max_width_chars(15)
label.set_halign(Gtk.Align.START)
label.add_css_class("heading")
info.append(label)
# Controls row: [Play] [CE] ---- [Switch]
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
row.set_halign(Gtk.Align.FILL)
# Play button
btn_play = Gtk.Button()
btn_play.set_icon_name("media-playback-start-symbolic")
btn_play.add_css_class("flat")
btn_play.add_css_class("tile-btn")
btn_play.set_tooltip_text(_("Launch game"))
btn_play.connect("clicked", self._on_play_clicked)
row.append(btn_play)
# CE button
btn_ce = Gtk.Button()
btn_ce.set_icon_name("utilities-terminal-symbolic")
btn_ce.add_css_class("flat")
btn_ce.add_css_class("tile-btn")
btn_ce.set_tooltip_text(_("Launch Cheat Engine"))
btn_ce.connect("clicked", self._on_ce_clicked)
row.append(btn_ce)
# Spacer
spacer = Gtk.Box()
spacer.set_hexpand(True)
row.append(spacer)
# Active switch
self._switch = Gtk.Switch()
self._switch.set_valign(Gtk.Align.CENTER)
self._switch.set_active(self._is_active)
self._switch.connect("state-set", self._on_switch_toggled)
row.append(self._switch)
info.append(row)
self.add_overlay(info)
def _build_reload_button(self) -> None:
"""Create reload button in top-left corner."""
btn = Gtk.Button()
btn.set_icon_name("view-refresh-symbolic")
btn.add_css_class("flat")
btn.add_css_class("vual-reload-btn")
btn.set_valign(Gtk.Align.START)
btn.set_halign(Gtk.Align.START)
btn.set_margin_top(6)
btn.set_margin_start(6)
btn.set_tooltip_text(_("Refresh cover"))
btn.connect("clicked", self._on_reload_clicked)
self.add_overlay(btn)
def _build_running_badge(self) -> None:
"""Create running badge in top-right corner."""
self._badge = Gtk.Image.new_from_icon_name("media-playback-start-symbolic")
self._badge.set_pixel_size(14)
self._badge.add_css_class("vual-badge")
self._badge.set_valign(Gtk.Align.START)
self._badge.set_halign(Gtk.Align.END)
self._badge.set_margin_top(6)
self._badge.set_margin_end(6)
self._badge.set_visible(False)
self.add_overlay(self._badge)
# ─── Properties ───────────────────────────────────────────────────────────
@property
def app_id(self) -> str:
return self._app_id
@property
def name(self) -> str:
return self._name
@property
def is_active(self) -> bool:
return self._is_active
@property
def is_running(self) -> bool:
return self._is_running
# ─── Setters ──────────────────────────────────────────────────────────────
def set_active_silent(self, active: bool) -> None:
"""Set switch without triggering callback."""
self._is_active = active
self._switch.handler_block_by_func(self._on_switch_toggled)
self._switch.set_active(active)
self._switch.handler_unblock_by_func(self._on_switch_toggled)
def set_running(self, running: bool) -> None:
"""Update running badge visibility."""
self._is_running = running
self._badge.set_visible(running)
def set_cover(self, pixbuf: GdkPixbuf.Pixbuf | None) -> None:
"""Set cover image from pixbuf."""
self._picture.remove_css_class("skeleton")
if pixbuf:
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
self._picture.set_paintable(texture)
else:
self._picture.set_paintable(None)
def resize(self, width: int) -> None:
"""Resize tile to new width."""
self._width = width
self._height = int(width * COVER_RATIO)
self.set_size_request(self._width, self._height)
self._picture.set_size_request(self._width, self._height)
# ─── Callbacks ────────────────────────────────────────────────────────────
def _on_play_clicked(self, _btn: Gtk.Button) -> None:
"""Emit play signal."""
if hasattr(self, "_on_launch"):
self._on_launch(self._app_id, self._name)
def _on_ce_clicked(self, _btn: Gtk.Button) -> None:
"""Emit CE signal."""
if hasattr(self, "_on_launch_ce"):
self._on_launch_ce(self._app_id, self._name)
def _on_switch_toggled(self, switch: Gtk.Switch, state: bool) -> bool:
"""Handle activation toggle."""
self._is_active = state
if hasattr(self, "_on_toggle"):
self._on_toggle(self._app_id, state)
return False
def _on_reload_clicked(self, _btn: Gtk.Button) -> None:
"""Emit reload signal."""
if hasattr(self, "_on_reload"):
self._on_reload(self._app_id)
# ─── Connect handlers ─────────────────────────────────────────────────────
def connect_launch(self, callback) -> None:
self._on_launch = callback
def connect_launch_ce(self, callback) -> None:
self._on_launch_ce = callback
def connect_toggle(self, callback) -> None:
self._on_toggle = callback
def connect_reload(self, callback) -> None:
self._on_reload = callback
# ═══════════════════════════════════════════════════════════════════════════════
# MainPage — the main games grid
# ═══════════════════════════════════════════════════════════════════════════════
class MainPage(Adw.Bin):
"""Main page with games grid."""
__gtype_name__ = "VualMainPage"
def __init__(self, config: Config, window: VualWindow):
super().__init__()
self._win = window
self._cfg = config
self._tiles: dict[str, GameTile] = {}
self._search_text = ""
self._loading = False
self._auto_ce: str | None = None
self._executor = ThreadPoolExecutor(max_workers=8)
# Search entry (exposed for header bar)
self._search = Gtk.SearchEntry()
self._search.set_placeholder_text(_("Search..."))
self._search.connect("search-changed", self._on_search_changed)
# Build UI
self._build_ui()
# Load games on startup
GLib.idle_add(self._load)
def _build_ui(self) -> None:
"""Build the main page UI."""
# Stack for loading/content states
self._stack = Gtk.Stack()
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.set_child(self._stack)
# Loading spinner
spinner = Gtk.Spinner(spinning=True)
spinner.set_size_request(48, 48)
spinner.set_valign(Gtk.Align.CENTER)
spinner.set_halign(Gtk.Align.CENTER)
self._stack.add_named(spinner, "loading")
# Empty state
empty = Adw.StatusPage(
icon_name="view-grid-symbolic",
title=_("Games not found"),
description=_("Check Steam path in settings"),
)
self._stack.add_named(empty, "empty")
# Scrolled grid
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
# FlowBox grid
self._grid = Gtk.FlowBox()
self._grid.set_homogeneous(False)
self._grid.set_valign(Gtk.Align.START)
self._grid.set_halign(Gtk.Align.CENTER)
self._grid.set_row_spacing(GRID_SPACING)
self._grid.set_column_spacing(GRID_SPACING)
self._grid.set_selection_mode(Gtk.SelectionMode.NONE)
self._grid.set_min_children_per_line(1)
self._grid.set_max_children_per_line(20)
self._grid.set_margin_start(GRID_MARGIN)
self._grid.set_margin_end(GRID_MARGIN)
self._grid.set_margin_top(GRID_MARGIN)
self._grid.set_margin_bottom(GRID_MARGIN)
self._grid.add_css_class("vual-grid")
self._grid.set_filter_func(self._filter_func)
self._grid.set_sort_func(self._sort_func)
scroll.set_child(self._grid)
self._stack.add_named(scroll, "games")
self._stack.set_visible_child_name("loading")
# ─── Public API ───────────────────────────────────────────────────────────
def reload(self) -> None:
"""Reload games list."""
self._load_games()
# Alias for window.py compatibility
_load = reload
def search(self, text: str) -> None:
"""Filter games by search text."""
self._search_text = text.lower().strip()
self._grid.invalidate_filter()
def _on_search_changed(self, entry: Gtk.SearchEntry) -> None:
"""Handle search entry changes."""
self.search(entry.get_text())
def apply_tile_size(self) -> None:
"""Apply current tile size from config."""
width = TILE_SIZES.get(self._cfg.tile_size, 150)
for tile in self._tiles.values():
tile.resize(width)
# Reload cover at new size
self._load_cover(tile)
def apply_sort(self) -> None:
"""Reapply sort order."""
self._grid.invalidate_sort()
# Alias for window.py compatibility
_update_sort = apply_sort
def update_running_state(self, running_ids: set[str]) -> None:
"""Update running badges based on active processes."""
for app_id, tile in self._tiles.items():
tile.set_running(app_id in running_ids)
@property
def games_count(self) -> int:
"""Number of loaded games."""
return len(self._tiles)
# ─── Filter & Sort ────────────────────────────────────────────────────────
def _filter_func(self, child: Gtk.FlowBoxChild) -> bool:
"""Filter function for FlowBox."""
if not self._search_text:
return True
tile = child.get_child()
if isinstance(tile, GameTile):
return self._search_text in tile.name.lower()
return True
def _sort_func(self, a: Gtk.FlowBoxChild, b: Gtk.FlowBoxChild) -> int:
"""Sort function for FlowBox."""
tile_a = a.get_child()
tile_b = b.get_child()
if not isinstance(tile_a, GameTile) or not isinstance(tile_b, GameTile):
return 0
if self._cfg.sort_by == "status":
# Active first, then by name
if tile_a.is_active != tile_b.is_active:
return -1 if tile_a.is_active else 1
# Alphabetical
return (tile_a.name.lower() > tile_b.name.lower()) - (tile_a.name.lower() < tile_b.name.lower())
# ─── Loading ──────────────────────────────────────────────────────────────
def _load_games(self) -> None:
"""Load games from Steam in background thread."""
if self._loading:
return
self._loading = True
self._stack.set_visible_child_name("loading")
def worker():
steamapps = self._cfg.steamapps_path
if not steamapps.exists():
GLib.idle_add(self._on_games_loaded, [])
return
# Get installed games using steam module
included, _ = steam.get_installed_games(
steamapps, self._cfg.excluded_app_patterns
)
# Check activation status for each game
localconfig = steam.find_localconfig_vdf(self._cfg.steam_path)
games = []
for game in included:
app_id = game["app_id"]
name = game["name"]
is_active = False
if localconfig:
opts = steam.get_launch_options(app_id, localconfig)
if opts and "protonhax" in opts.lower():
is_active = True
games.append((app_id, name, is_active))
GLib.idle_add(self._on_games_loaded, games)
threading.Thread(target=worker, daemon=True).start()
def _on_games_loaded(self, games: list[tuple[str, str, bool]]) -> None:
"""Handle loaded games on main thread."""
self._loading = False
# Clear existing tiles
while child := self._grid.get_first_child():
self._grid.remove(child)
self._tiles.clear()
if not games:
self._stack.set_visible_child_name("empty")
self._win.update_counter(0)
return
# Get tile size
width = TILE_SIZES.get(self._cfg.tile_size, 150)
# Create tiles
for app_id, name, is_active in games:
tile = GameTile(app_id, name, is_active, width)
tile.connect_launch(self._on_launch)
tile.connect_launch_ce(self._on_launch_ce)
tile.connect_toggle(self._on_toggle)
tile.connect_reload(self._on_reload_cover)
self._tiles[app_id] = tile
self._grid.append(tile)
# Load cover in background
self._load_cover(tile)
self._grid.invalidate_sort()
self._stack.set_visible_child_name("games")
self._win.update_counter(len(games))
# Check running games
self._check_running()
def _load_cover(self, tile: GameTile) -> None:
"""Load cover image for tile in background."""
app_id = tile.app_id
width = tile._width
height = tile._height
def worker():
path = _download_cover(app_id)
if path:
pixbuf = _load_pixbuf(path, width, height)
GLib.idle_add(tile.set_cover, pixbuf)
else:
GLib.idle_add(tile.set_cover, None)
self._executor.submit(worker)
def _on_reload_cover(self, app_id: str) -> None:
"""Force reload cover from Steam."""
tile = self._tiles.get(app_id)
if not tile:
return
# Delete cached cover
cache = _cache_path(app_id)
if cache.exists():
cache.unlink()
# Re-add skeleton class and reload
tile._picture.add_css_class("skeleton")
tile._picture.set_paintable(None)
self._load_cover(tile)
# ─── Running detection ────────────────────────────────────────────────────
def _check_running(self) -> None:
"""Check which games are currently running."""
def worker():
running_ids = set(protonhax.list_running())
GLib.idle_add(self._on_running_checked, running_ids)
threading.Thread(target=worker, daemon=True).start()
def _on_running_checked(self, running_ids: set[str]) -> None:
"""Update running state on main thread."""
self.update_running_state(running_ids)
# Auto-launch CE if needed
if self._auto_ce and self._auto_ce in running_ids:
app_id = self._auto_ce
tile = self._tiles.get(app_id)
name = tile.name if tile else app_id
self._auto_ce = None
GLib.timeout_add_seconds(2, lambda: self._do_ce(app_id, name))
# Schedule next check
GLib.timeout_add_seconds(5, self._check_running)
# ─── Actions ──────────────────────────────────────────────────────────────
def _on_launch(self, app_id: str, name: str) -> None:
"""Launch game via Steam."""
try:
protonhax.ensure_installed()
except OSError:
pass
subprocess.Popen(
["steam", f"steam://rungameid/{app_id}"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self._win.show_toast(_("Launching %s...") % name)
self._auto_ce = app_id
def _on_launch_ce(self, app_id: str, name: str) -> None:
"""Launch CE for a game."""
self._do_ce(app_id, name)
def _do_ce(self, app_id: str, name: str) -> None:
"""Actually launch Cheat Engine."""
if not self._cfg.ce_exists:
self._win.show_toast(_("CE not found: %s") % self._cfg.ce_executable)
return
args = None
if self._cfg.ce_language != "system":
args = ["--LANG", self._cfg.ce_language]
proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args)
if proc:
self._win.show_toast(_("CE launched for %s") % name)
else:
self._win.show_toast(_("Failed to launch CE for %s") % name)
def _on_toggle(self, app_id: str, active: bool) -> None:
"""Handle activation toggle."""
tile = self._tiles.get(app_id)
if not tile:
return
if active:
self._apply_single(app_id, tile.name, "set")
else:
self._apply_single(app_id, tile.name, "remove")
def _apply_single(self, app_id: str, name: str, mode: str) -> None:
"""Apply launch options for a single game."""
if steam.is_steam_running():
self._win.show_toast(_("Close Steam to change settings"))
# Revert switch
tile = self._tiles.get(app_id)
if tile:
tile.set_active_silent(mode != "set")
return
try:
protonhax.ensure_installed()
except OSError as e:
self._win.show_toast(f"protonhax: {e}")
return
localconfig = steam.find_localconfig_vdf(self._cfg.steam_path)
if not localconfig:
self._win.show_toast(_("localconfig.vdf not found"))
return
if mode == "set":
ok = steam.set_launch_options(app_id, self._cfg.launch_options_template, localconfig)
verb = _("set")
else:
ok = steam.remove_launch_options(app_id, localconfig)
verb = _("removed")
if ok:
self._win.show_toast(_("LaunchOptions %s for %s") % (verb, name))
else:
self._win.show_toast(_("Error changing settings for %s") % name)
# Revert switch
tile = self._tiles.get(app_id)
if tile:
tile.set_active_silent(mode != "set")
# ─── Batch operations ─────────────────────────────────────────────────────
def _on_enable_all(self, _btn) -> None:
"""Show dialog for enabling all games."""
dlg = Adw.AlertDialog(
heading=_("Enable for all games?"),
body=self._cfg.launch_options_template,
)
dlg.add_response("cancel", _("Cancel"))
dlg.add_response("ok", _("Enable"))
dlg.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED)
dlg.choose(self._win, None, self._finish_enable)
def _finish_enable(self, dlg, res) -> None:
if dlg.choose_finish(res) != "ok":
return
try:
protonhax.ensure_installed()
except OSError as e:
self._win.show_toast(f"protonhax: {e}")
return
ids = [aid for aid, t in self._tiles.items() if not t.is_active]
if ids:
self._apply_batch(ids, "set")
def _on_disable_all(self, _btn) -> None:
"""Show dialog for disabling all games."""
dlg = Adw.AlertDialog(
heading=_("Disable for all games?"),
body=_("LaunchOptions will be removed."),
)
dlg.add_response("cancel", _("Cancel"))
dlg.add_response("ok", _("Disable"))
dlg.set_response_appearance("ok", Adw.ResponseAppearance.DESTRUCTIVE)
dlg.choose(self._win, None, self._finish_disable)
def _finish_disable(self, dlg, res) -> None:
if dlg.choose_finish(res) != "ok":
return
ids = [aid for aid, t in self._tiles.items() if t.is_active]
if ids:
self._apply_batch(ids, "remove")
def _apply_batch(self, ids: list[str], mode: str) -> None:
"""Apply changes to multiple games."""
if steam.is_steam_running():
self._win.show_toast(_("Restarting Steam to apply..."))
def wait_and_apply():
steam.shutdown_steam()
if not steam.wait_steam_exit(30):
GLib.idle_add(self._win.show_toast, _("Steam did not exit"))
return
GLib.idle_add(self._write_batch, ids, mode, True)
threading.Thread(target=wait_and_apply, daemon=True).start()
else:
self._write_batch(ids, mode, False)
def _write_batch(self, ids: list[str], mode: str, restart: bool) -> None:
"""Write launch options for multiple games."""
localconfig = steam.find_localconfig_vdf(self._cfg.steam_path)
if not localconfig:
self._win.show_toast(_("localconfig.vdf not found"))
return
ok = 0
for app_id in ids:
if mode == "set":
result = steam.set_launch_options(app_id, self._cfg.launch_options_template, localconfig)
else:
result = steam.remove_launch_options(app_id, localconfig)
if result:
ok += 1
tile = self._tiles.get(app_id)
if tile:
tile.set_active_silent(mode == "set")
if mode == "set":
self._win.show_toast(_("LaunchOptions set for %d games") % ok)
else:
self._win.show_toast(_("LaunchOptions removed for %d games") % ok)
if restart:
steam.start_steam()
def schedule_auto_launch(self, app_id: str) -> None:
"""Schedule auto CE launch for a game."""
self._auto_ce = app_id
+783
View File
@@ -0,0 +1,783 @@
"""Preferences window — clean libadwaita implementation.
Uses Adw.PreferencesWindow with auto-save on changes.
"""
from __future__ import annotations
import threading
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk
from vual import cheatengine, protonhax, wine_theme
from vual.config import Config
from vual.i18n import _
class PreferencesWindow(Adw.PreferencesWindow):
"""Preferences window with auto-save."""
def __init__(self, config: Config, **kwargs) -> None:
super().__init__(
title=_("Preferences"),
**kwargs,
)
self._config = config
self._exclusion_rows: list[Adw.EntryRow] = []
self._release_info: dict | None = None
self._building = True # Prevent saves during initial build
self._build_pages()
self._refresh_status()
self._building = False
def _save(self) -> None:
"""Save config if not building UI."""
if self._building:
return
self._config.save()
# ════════════════════════════════════════════════════════════════
# Pages
# ════════════════════════════════════════════════════════════════
def _build_pages(self) -> None:
"""Build all preference pages."""
for build in (self._build_appearance_page, self._build_steam_page, self._build_ce_page):
page = build()
page.connect("map", self._on_page_mapped)
self.add(page)
def _on_page_mapped(self, page: Adw.PreferencesPage) -> None:
"""Reset focus when switching pages to avoid auto-selecting EntryRow."""
GLib.idle_add(self.set_focus, None)
# ────────────────────────────────────────────────────────────────
# Appearance Page
# ────────────────────────────────────────────────────────────────
def _build_appearance_page(self) -> Adw.PreferencesPage:
page = Adw.PreferencesPage(
title=_("Appearance"),
icon_name="preferences-desktop-appearance-symbolic",
)
# Theme group
theme_group = Adw.PreferencesGroup(title=_("Theme"))
page.add(theme_group)
self._theme_row = Adw.ComboRow(
title=_("Color scheme"),
subtitle=_("Light, dark, or system"),
)
model = Gtk.StringList.new([_("System"), _("Light"), _("Dark")])
self._theme_row.set_model(model)
idx = {"system": 0, "light": 1, "dark": 2}.get(self._config.color_scheme, 0)
self._theme_row.set_selected(idx)
self._theme_row.connect("notify::selected", self._on_theme_changed)
theme_group.add(self._theme_row)
# Language selection
self._lang_app_row = Adw.ComboRow(
title=_("Language"),
subtitle=_("Application interface language"),
)
lang_app_model = Gtk.StringList.new([_("System"), "English", "Русский"])
self._lang_app_row.set_model(lang_app_model)
lang_app_idx = {"system": 0, "en": 1, "ru": 2}.get(self._config.app_language, 0)
self._lang_app_row.set_selected(lang_app_idx)
self._lang_app_row.connect("notify::selected", self._on_app_lang_changed)
theme_group.add(self._lang_app_row)
# Grid group
grid_group = Adw.PreferencesGroup(
title=_("Grid"),
description=_("Game display settings"),
)
page.add(grid_group)
self._tile_row = Adw.ComboRow(
title=_("Tile size"),
subtitle=_("Cover size in library"),
)
tile_model = Gtk.StringList.new([_("Small (120px)"), _("Medium (150px)"), _("Large (180px)")])
self._tile_row.set_model(tile_model)
tile_idx = {"small": 0, "medium": 1, "large": 2}.get(self._config.tile_size, 1)
self._tile_row.set_selected(tile_idx)
self._tile_row.connect("notify::selected", self._on_tile_size_changed)
grid_group.add(self._tile_row)
self._sort_row = Adw.ComboRow(
title=_("Sort"),
subtitle=_("Default game order"),
)
sort_model = Gtk.StringList.new([_("By name"), _("By status")])
self._sort_row.set_model(sort_model)
sort_idx = {"name": 0, "status": 1}.get(self._config.sort_by, 0)
self._sort_row.set_selected(sort_idx)
self._sort_row.connect("notify::selected", self._on_sort_changed)
grid_group.add(self._sort_row)
# Wine theme group
wine_group = Adw.PreferencesGroup(
title=_("Wine Theme"),
description=_("Color scheme for Cheat Engine and other Wine apps"),
)
page.add(wine_group)
self._wine_theme_row = Adw.ComboRow(
title=_("Color scheme"),
subtitle=_("Applied to all Proton prefixes"),
)
wine_model = Gtk.StringList.new([_("System"), _("Dark"), _("Light")])
self._wine_theme_row.set_model(wine_model)
wine_idx = {"system": 0, "dark": 1, "light": 2}.get(self._config.wine_theme, 0)
self._wine_theme_row.set_selected(wine_idx)
self._wine_theme_row.connect("notify::selected", self._on_wine_theme_changed)
# Refresh button
self._wine_refresh_btn = Gtk.Button(
icon_name="view-refresh-symbolic",
valign=Gtk.Align.CENTER,
css_classes=["flat"],
tooltip_text=_("Reapply theme"),
)
self._wine_refresh_btn.connect("clicked", self._on_wine_theme_refresh)
self._wine_theme_row.add_suffix(self._wine_refresh_btn)
wine_group.add(self._wine_theme_row)
return page
def _on_theme_changed(self, row: Adw.ComboRow, _pspec) -> None:
schemes = ["system", "light", "dark"]
self._config.color_scheme = schemes[row.get_selected()]
self._save()
self._apply_theme()
def _on_tile_size_changed(self, row: Adw.ComboRow, _pspec) -> None:
sizes = ["small", "medium", "large"]
self._config.tile_size = sizes[row.get_selected()]
self._save()
def _on_sort_changed(self, row: Adw.ComboRow, _pspec) -> None:
sorts = ["name", "status"]
self._config.sort_by = sorts[row.get_selected()]
self._save()
def _on_app_lang_changed(self, row: Adw.ComboRow, _pspec) -> None:
langs = ["system", "en", "ru"]
lang = langs[row.get_selected()]
self._config.app_language = lang
self._save()
# Show restart hint
self._show_toast(_("Restart app to apply language"))
def _apply_theme(self) -> None:
style = Adw.StyleManager.get_default()
schemes = {
"light": Adw.ColorScheme.FORCE_LIGHT,
"dark": Adw.ColorScheme.FORCE_DARK,
"system": Adw.ColorScheme.DEFAULT,
}
style.set_color_scheme(schemes.get(self._config.color_scheme, Adw.ColorScheme.DEFAULT))
def _get_system_theme(self) -> str:
"""Detect system color scheme. Returns 'dark' or 'light'."""
style = Adw.StyleManager.get_default()
return "dark" if style.get_dark() else "light"
def _on_wine_theme_changed(self, row: Adw.ComboRow, _pspec) -> None:
"""Handle Wine theme change — save and apply immediately."""
themes = ["system", "dark", "light"]
theme = themes[row.get_selected()]
self._config.wine_theme = theme
self._save()
# For "system", detect actual system theme
actual_theme = self._get_system_theme() if theme == "system" else theme
# Apply in background
row.set_sensitive(False)
self._wine_refresh_btn.set_sensitive(False)
def worker() -> None:
steamapps = self._config.steamapps_path
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
GLib.idle_add(self._on_wine_theme_applied, row, success, failed)
threading.Thread(target=worker, daemon=True).start()
def _on_wine_theme_applied(self, row: Adw.ComboRow, success: int, failed: int) -> None:
"""Handle Wine theme application result."""
row.set_sensitive(True)
self._wine_refresh_btn.set_sensitive(True)
if success > 0:
self._show_toast(_("Theme applied to %d prefixes") % success)
elif failed > 0:
self._show_toast(_("Error applying to %d prefixes") % failed)
def _on_wine_theme_refresh(self, _btn: Gtk.Button) -> None:
"""Refresh/reapply Wine theme to all prefixes."""
theme = self._config.wine_theme
actual_theme = self._get_system_theme() if theme == "system" else theme
self._wine_theme_row.set_sensitive(False)
self._wine_refresh_btn.set_sensitive(False)
def worker() -> None:
steamapps = self._config.steamapps_path
success, failed = wine_theme.apply_theme_to_all(steamapps, actual_theme)
GLib.idle_add(self._on_wine_theme_applied, self._wine_theme_row, success, failed)
threading.Thread(target=worker, daemon=True).start()
def _show_toast(self, message: str) -> None:
"""Show a toast notification."""
toast = Adw.Toast(title=message, timeout=3)
self.add_toast(toast)
# ────────────────────────────────────────────────────────────────
# Steam Page
# ────────────────────────────────────────────────────────────────
def _build_steam_page(self) -> Adw.PreferencesPage:
page = Adw.PreferencesPage(
title="Steam",
icon_name="folder-games-symbolic",
)
# Paths group
paths_group = Adw.PreferencesGroup(title=_("Paths"))
page.add(paths_group)
self._steam_row = Adw.EntryRow(title=_("Steam directory"))
self._steam_row.set_text(self._config.steam_path)
self._steam_row.connect("changed", self._on_steam_path_changed)
steam_browse = Gtk.Button(
icon_name="folder-open-symbolic",
valign=Gtk.Align.CENTER,
css_classes=["flat"],
tooltip_text=_("Choose folder"),
)
steam_browse.connect("clicked", self._on_browse_steam)
self._steam_row.add_suffix(steam_browse)
paths_group.add(self._steam_row)
# Launch options group
launch_group = Adw.PreferencesGroup(
title=_("Launch options"),
description=_("LaunchOptions template for protonhax"),
)
page.add(launch_group)
self._template_row = Adw.EntryRow(title=_("Template"))
self._template_row.set_text(self._config.launch_options_template)
self._template_row.connect("changed", self._on_template_changed)
launch_group.add(self._template_row)
hint_row = Adw.ActionRow(
title="%COMMAND%",
subtitle=_("Original launch command is substituted"),
)
hint_row.set_activatable(False)
hint_row.add_css_class("dim-label")
launch_group.add(hint_row)
# Exclusions group
excl_group = Adw.PreferencesGroup(
title=_("Exclusions"),
description=_("Regex patterns to hide apps"),
)
page.add(excl_group)
self._excl_group = excl_group
for pattern in self._config.excluded_app_patterns:
self._add_exclusion_row(pattern)
add_row = Adw.ActionRow(title=_("Add pattern"))
add_btn = Gtk.Button(
icon_name="list-add-symbolic",
valign=Gtk.Align.CENTER,
css_classes=["flat"],
)
add_btn.connect("clicked", self._on_add_exclusion)
add_row.add_suffix(add_btn)
add_row.set_activatable_widget(add_btn)
excl_group.add(add_row)
self._add_excl_row = add_row
return page
def _on_steam_path_changed(self, row: Adw.EntryRow) -> None:
self._config.steam_path = row.get_text().strip()
self._save()
def _on_template_changed(self, row: Adw.EntryRow) -> None:
self._config.launch_options_template = row.get_text().strip()
self._save()
def _on_browse_steam(self, _btn: Gtk.Button) -> None:
dialog = Gtk.FileDialog(title=_("Select Steam directory"))
dialog.select_folder(self, None, self._on_steam_folder_selected)
def _on_steam_folder_selected(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
try:
folder = dialog.select_folder_finish(result)
if folder:
path = folder.get_path()
home = str(Path.home())
display = path.replace(home, "~", 1) if path.startswith(home) else path
self._steam_row.set_text(display)
except GLib.Error:
pass
def _add_exclusion_row(self, text: str = "") -> Adw.EntryRow:
row = Adw.EntryRow(title="Regex")
row.set_text(text)
row.connect("changed", self._on_exclusion_changed)
remove_btn = Gtk.Button(
icon_name="user-trash-symbolic",
valign=Gtk.Align.CENTER,
css_classes=["flat", "error"],
tooltip_text=_("Remove"),
)
remove_btn.connect("clicked", self._on_remove_exclusion, row)
row.add_suffix(remove_btn)
self._exclusion_rows.append(row)
self._excl_group.add(row)
return row
def _on_add_exclusion(self, _btn: Gtk.Button) -> None:
row = self._add_exclusion_row()
row.grab_focus()
def _on_remove_exclusion(self, _btn: Gtk.Button, row: Adw.EntryRow) -> None:
if row in self._exclusion_rows:
self._exclusion_rows.remove(row)
self._excl_group.remove(row)
self._sync_exclusions()
def _on_exclusion_changed(self, _row: Adw.EntryRow) -> None:
self._sync_exclusions()
def _sync_exclusions(self) -> None:
self._config.excluded_app_patterns = [
row.get_text().strip()
for row in self._exclusion_rows
if row.get_text().strip()
]
self._save()
# ────────────────────────────────────────────────────────────────
# Cheat Engine Page
# ────────────────────────────────────────────────────────────────
def _build_ce_page(self) -> Adw.PreferencesPage:
page = Adw.PreferencesPage(
title="Cheat Engine",
icon_name="applications-games-symbolic",
)
# Status group
status_group = Adw.PreferencesGroup(title=_("Status"))
page.add(status_group)
self._ce_status_row = Adw.ActionRow(
title="Cheat Engine",
subtitle=_("Checking..."),
)
self._ce_status_row.set_activatable(False)
status_group.add(self._ce_status_row)
self._ph_status_row = Adw.ActionRow(
title="protonhax",
subtitle=_("Checking..."),
)
self._ph_status_row.set_activatable(False)
status_group.add(self._ph_status_row)
# Installation group
install_group = Adw.PreferencesGroup(
title=_("Installation"),
description=_("Cheat Engine setup and download"),
)
page.add(install_group)
self._ce_path_row = Adw.EntryRow(title=_("Executable"))
self._ce_path_row.set_text(self._config.ce_executable)
self._ce_path_row.connect("changed", self._on_ce_path_changed)
browse_btn = Gtk.Button(
icon_name="document-open-symbolic",
valign=Gtk.Align.CENTER,
css_classes=["flat"],
tooltip_text=_("Choose file"),
)
browse_btn.connect("clicked", self._on_browse_ce)
self._ce_path_row.add_suffix(browse_btn)
install_group.add(self._ce_path_row)
self._dl_row = Adw.ActionRow(
title=_("Latest version"),
subtitle=_("Click Check"),
)
self._check_btn = Gtk.Button(
label=_("Check"),
valign=Gtk.Align.CENTER,
)
self._check_btn.connect("clicked", self._on_check_release)
self._dl_row.add_suffix(self._check_btn)
install_group.add(self._dl_row)
self._progress_row = Adw.ActionRow(
title=_("Download"),
subtitle="",
visible=False,
)
self._progress = Gtk.ProgressBar(
valign=Gtk.Align.CENTER,
show_text=True,
)
self._progress.set_size_request(150, -1)
self._progress_row.add_suffix(self._progress)
install_group.add(self._progress_row)
# protonhax group
ph_group = Adw.PreferencesGroup(
title="protonhax",
description=_("Required for attaching to Proton games"),
)
page.add(ph_group)
ph_row = Adw.ActionRow(
title=_("Install or update"),
subtitle=_("Downloads latest version from GitHub"),
)
self._ph_btn = Gtk.Button(
label=_("Install"),
valign=Gtk.Align.CENTER,
)
self._ph_btn.connect("clicked", self._on_install_protonhax)
ph_row.add_suffix(self._ph_btn)
ph_group.add(ph_row)
# Language group
lang_group = Adw.PreferencesGroup(
title=_("Language"),
description=_("Cheat Engine interface language"),
)
page.add(lang_group)
self._lang_row = Adw.ComboRow(
title=_("Interface"),
subtitle=_("Requires CE restart"),
)
lang_model = Gtk.StringList.new([_("System"), _("Russian")])
self._lang_row.set_model(lang_model)
lang_idx = {"system": 0, "ru_RU": 1}.get(self._config.ce_language, 0)
self._lang_row.set_selected(lang_idx)
self._lang_row.connect("notify::selected", self._on_lang_changed)
lang_group.add(self._lang_row)
self._loc_row = Adw.ActionRow(
title=_("Russian localization"),
subtitle=_("Checking..."),
)
self._loc_btn = Gtk.Button(
label=_("Install"),
valign=Gtk.Align.CENTER,
)
self._loc_btn.connect("clicked", self._on_install_localization)
self._loc_row.add_suffix(self._loc_btn)
lang_group.add(self._loc_row)
# Debug group
debug_group = Adw.PreferencesGroup(title=_("Debug"))
page.add(debug_group)
test_row = Adw.ActionRow(
title=_("Test launch"),
subtitle=_("Launch CE via Wine (without game binding)"),
)
self._test_btn = Gtk.Button(
label=_("Launch"),
valign=Gtk.Align.CENTER,
css_classes=["suggested-action"],
)
self._test_btn.connect("clicked", self._on_test_launch)
test_row.add_suffix(self._test_btn)
debug_group.add(test_row)
return page
def _on_ce_path_changed(self, row: Adw.EntryRow) -> None:
self._config.ce_executable = row.get_text().strip()
self._save()
self._refresh_status()
def _on_browse_ce(self, _btn: Gtk.Button) -> None:
dialog = Gtk.FileDialog(title=_("Select CE executable"))
filters = Gio.ListStore.new(Gtk.FileFilter)
exe_filter = Gtk.FileFilter()
exe_filter.set_name(_("Executable files (*.exe)"))
exe_filter.add_pattern("*.exe")
filters.append(exe_filter)
all_filter = Gtk.FileFilter()
all_filter.set_name(_("All files"))
all_filter.add_pattern("*")
filters.append(all_filter)
dialog.set_filters(filters)
dialog.open(self, None, self._on_ce_file_selected)
def _on_ce_file_selected(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult) -> None:
try:
gfile = dialog.open_finish(result)
if gfile:
path = gfile.get_path()
home = str(Path.home())
display = path.replace(home, "~", 1) if path.startswith(home) else path
self._ce_path_row.set_text(display)
except GLib.Error:
pass
# ────────────────────────────────────────────────────────────────
# Status & Actions
# ────────────────────────────────────────────────────────────────
def _refresh_status(self) -> None:
"""Update CE and protonhax status."""
# CE status
if self._config.ce_exists:
version = cheatengine.detect_version(self._config.ce_executable_path)
ver_str = f" ({version})" if version else ""
self._ce_status_row.set_subtitle(_("✓ Installed") + ver_str)
self._test_btn.set_sensitive(True)
else:
self._ce_status_row.set_subtitle(_("✗ Not found"))
self._test_btn.set_sensitive(False)
# protonhax status
ph_path = protonhax.find_installed()
if ph_path:
if protonhax.is_managed():
if protonhax.needs_update():
self._ph_status_row.set_subtitle(_("⟳ Update available"))
self._ph_btn.set_label(_("Refresh"))
else:
self._ph_status_row.set_subtitle(_("✓ Installed"))
self._ph_btn.set_label(_("Reinstall"))
else:
self._ph_status_row.set_subtitle(_("✓ External: %s") % ph_path)
else:
self._ph_status_row.set_subtitle(_("✗ Not installed"))
self._ph_btn.set_label(_("Install"))
# Localization status
if self._config.ce_exists:
if cheatengine.is_localization_installed(self._config.ce_executable_path):
self._loc_row.set_subtitle(_("✓ Installed"))
self._loc_btn.set_label(_("Reinstall"))
else:
self._loc_row.set_subtitle(_("Not installed"))
self._loc_btn.set_label(_("Install"))
self._loc_btn.set_sensitive(True)
else:
self._loc_row.set_subtitle(_("Cheat Engine required"))
self._loc_btn.set_sensitive(False)
def _on_test_launch(self, _btn: Gtk.Button) -> None:
if not self._config.ce_exists:
self.add_toast(Adw.Toast(title=_("Cheat Engine not found")))
return
wine = cheatengine.find_proton_wine(self._config.steam_path)
if not wine:
self.add_toast(Adw.Toast(title=_("Wine not found — install Proton")))
return
import os
import subprocess
env = os.environ.copy()
prefix = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
prefix.mkdir(parents=True, exist_ok=True)
env["WINEPREFIX"] = str(prefix)
cmd = [str(wine), str(self._config.ce_executable_path)]
if self._config.ce_language != "system":
cmd.extend(["--LANG", self._config.ce_language])
try:
subprocess.Popen(
cmd,
env=env,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
self.add_toast(Adw.Toast(title=_("Cheat Engine launched")))
except OSError as e:
self.add_toast(Adw.Toast(title=_("Error: %s") % e))
def _on_install_protonhax(self, _btn: Gtk.Button) -> None:
try:
protonhax.ensure_installed()
self._refresh_status()
self.add_toast(Adw.Toast(title=_("protonhax installed")))
except OSError as e:
self.add_toast(Adw.Toast(title=_("Error: %s") % e))
def _on_lang_changed(self, row: Adw.ComboRow, _pspec) -> None:
langs = ["system", "ru_RU"]
self._config.ce_language = langs[row.get_selected()]
self._save()
def _on_install_localization(self, btn: Gtk.Button) -> None:
if not self._config.ce_exists:
self.add_toast(Adw.Toast(title=_("Cheat Engine not found")))
return
btn.set_sensitive(False)
self._loc_row.set_subtitle(_("Downloading..."))
def worker() -> None:
ok = cheatengine.install_localization(self._config.ce_executable_path)
GLib.idle_add(self._on_localization_done, ok, btn)
threading.Thread(target=worker, daemon=True).start()
def _on_localization_done(self, success: bool, btn: Gtk.Button) -> None:
btn.set_sensitive(True)
self._refresh_status()
if success:
self.add_toast(Adw.Toast(title=_("Localization installed")))
else:
self.add_toast(Adw.Toast(title=_("Failed to install localization")))
# ────────────────────────────────────────────────────────────────
# CE Download
# ────────────────────────────────────────────────────────────────
def _on_check_release(self, _btn: Gtk.Button) -> None:
self._dl_row.set_subtitle(_("Checking..."))
self._check_btn.set_sensitive(False)
def worker() -> None:
info = cheatengine.get_latest_release()
GLib.idle_add(self._on_release_checked, info)
threading.Thread(target=worker, daemon=True).start()
def _on_release_checked(self, info: dict | None) -> None:
self._check_btn.set_sensitive(True)
if not info or not info.get("url"):
self._dl_row.set_subtitle(_("Failed to get info"))
return
self._release_info = info
version = info.get("version", "?")
size_mb = info.get("size", 0) / 1_048_576
subtitle = f"v{version}"
if size_mb > 0:
subtitle += f" ({size_mb:.1f} " + _("MB") + ")"
self._dl_row.set_subtitle(subtitle)
# Replace check button with download button
self._dl_row.remove(self._check_btn)
dl_btn = Gtk.Button(
label=_("Download"),
valign=Gtk.Align.CENTER,
css_classes=["suggested-action"],
)
dl_btn.connect("clicked", self._on_download_ce)
self._dl_row.add_suffix(dl_btn)
self._download_btn = dl_btn
def _on_download_ce(self, btn: Gtk.Button) -> None:
if not self._release_info:
return
url = self._release_info["url"]
name = self._release_info.get("name", "CheatEngine.exe")
cache_dir = Path.home() / ".cache" / "vual"
cache_dir.mkdir(parents=True, exist_ok=True)
installer = cache_dir / name
dest_dir = Path.home() / ".local" / "share" / "vual" / "cheatengine"
self._progress_row.set_visible(True)
self._progress_row.set_subtitle(_("Downloading: %s") % name)
self._progress.set_fraction(0)
btn.set_sensitive(False)
def update_progress(frac: float) -> None:
GLib.idle_add(self._progress.set_fraction, frac)
def worker() -> None:
ok = cheatengine.download_file(url, installer, progress_cb=update_progress)
if not ok:
GLib.idle_add(self._download_failed, btn)
return
GLib.idle_add(self._start_extraction, installer, dest_dir, btn)
threading.Thread(target=worker, daemon=True).start()
def _download_failed(self, btn: Gtk.Button) -> None:
self._progress_row.set_visible(False)
btn.set_sensitive(True)
self.add_toast(Adw.Toast(title=_("Download failed")))
def _start_extraction(self, installer: Path, dest_dir: Path, btn: Gtk.Button) -> None:
self._progress_row.set_subtitle(_("Extracting..."))
self._progress.set_fraction(0.5)
def worker() -> None:
extracted = cheatengine.extract_installer(
installer, dest_dir, self._config.steam_path
)
exe = cheatengine.find_executable(dest_dir) if extracted else None
GLib.idle_add(self._extraction_done, exe, extracted, installer, btn)
threading.Thread(target=worker, daemon=True).start()
def _extraction_done(
self,
exe_path: Path | None,
extracted: bool,
installer: Path,
btn: Gtk.Button,
) -> None:
self._progress_row.set_visible(False)
btn.set_sensitive(True)
if exe_path:
home = str(Path.home())
display = str(exe_path).replace(home, "~", 1)
self._config.ce_executable = display
self._ce_path_row.set_text(display)
self._save()
self._refresh_status()
installer.unlink(missing_ok=True)
self.add_toast(Adw.Toast(title=_("Cheat Engine installed")))
elif extracted:
self._refresh_status()
self.add_toast(Adw.Toast(title=_("Extracted — specify path manually")))
else:
self.add_toast(Adw.Toast(title=_("Extraction failed")))
+128
View File
@@ -0,0 +1,128 @@
"""Main application window — single-page tile grid."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
from vual import APP_NAME # noqa: E402
from vual.config import Config # noqa: E402
from vual.i18n import _ # noqa: E402
class VualWindow(Adw.ApplicationWindow):
def __init__(self, config: Config, **kwargs) -> None:
super().__init__(
default_width=config.window_width,
default_height=config.window_height,
title=APP_NAME,
**kwargs,
)
self.config = config
self._build_ui()
# ── UI construction ──────────────────────────────────────────
def _build_ui(self) -> None:
self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay)
toolbar_view = Adw.ToolbarView()
self.toast_overlay.set_child(toolbar_view)
# Single main page
from vual.ui.main_page import MainPage
self.main_page = MainPage(config=self.config, window=self)
# Header bar
header = Adw.HeaderBar()
header.add_css_class("flat")
# Title widget: search + counter
title_box = Gtk.Box(spacing=8)
title_box.append(self.main_page._search)
self._counter = Gtk.Label(css_classes=["games-counter"])
title_box.append(self._counter)
header.set_title_widget(title_box)
b_refresh = Gtk.Button(
icon_name="view-refresh-symbolic",
css_classes=["flat", "header-btn"],
tooltip_text=_("Refresh"),
)
b_refresh.connect("clicked", lambda _: self.main_page._load())
header.pack_start(b_refresh)
b_on = Gtk.Button(
icon_name="object-select-symbolic",
css_classes=["flat", "header-btn"],
tooltip_text=_("Enable All"),
)
b_on.connect("clicked", self.main_page._on_enable_all)
header.pack_start(b_on)
b_off = Gtk.Button(
icon_name="edit-clear-all-symbolic",
css_classes=["flat", "header-btn"],
tooltip_text=_("Disable All"),
)
b_off.connect("clicked", self.main_page._on_disable_all)
header.pack_start(b_off)
# Sort menu
sort_menu = Gio.Menu()
sort_menu.append(_("By name"), "win.sort::name")
sort_menu.append(_("By status"), "win.sort::status")
sort_btn = Gtk.MenuButton(
icon_name="view-sort-descending-symbolic",
css_classes=["flat", "header-btn"],
menu_model=sort_menu,
tooltip_text=_("Sort"),
)
header.pack_end(sort_btn)
# Sort action
sort_action = Gio.SimpleAction.new("sort", GLib.VariantType.new("s"))
sort_action.connect("activate", self._on_sort_changed)
self.add_action(sort_action)
menu_button = Gtk.MenuButton(
icon_name="open-menu-symbolic",
css_classes=["flat", "header-btn"],
menu_model=self._build_menu(),
)
header.pack_end(menu_button)
toolbar_view.add_top_bar(header)
toolbar_view.set_content(self.main_page)
def _on_sort_changed(self, action: Gio.SimpleAction, param: GLib.Variant) -> None:
sort_by = param.get_string()
self.config.sort_by = sort_by
self.main_page._update_sort()
def update_counter(self, count: int) -> None:
"""Update the games counter label."""
self._counter.set_label(_("%d games") % count)
# ── App menu ─────────────────────────────────────────────────
def _build_menu(self) -> Gio.Menu:
menu = Gio.Menu()
menu.append(_("Preferences"), "app.preferences")
menu.append(_("About"), "app.about")
menu.append(_("Quit"), "app.quit")
return menu
# ── Toast helper ─────────────────────────────────────────────
def show_toast(self, message: str, timeout: int = 3) -> None:
toast = Adw.Toast(title=message, timeout=timeout)
self.toast_overlay.add_toast(toast)
return False
+406
View File
@@ -0,0 +1,406 @@
"""Wine theme management for Proton prefixes.
Safely applies dark/light color schemes to Wine prefixes via registry.
Only modifies string and dword values — never touches binary/hex data.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterator
# Registry section pattern
_SECTION_RE = re.compile(r'^\[([^\]]+)\]', re.MULTILINE)
# Wine dark color scheme (Control Panel\Colors)
# Format: "R G B" strings
DARK_COLORS = {
"ActiveBorder": "49 54 59",
"ActiveTitle": "49 54 59",
"AppWorkSpace": "30 30 30",
"Background": "30 30 30",
"ButtonAlternateFace": "49 54 59",
"ButtonDkShadow": "20 20 20",
"ButtonFace": "49 54 59",
"ButtonHilight": "70 75 80",
"ButtonLight": "60 65 70",
"ButtonShadow": "35 38 41",
"ButtonText": "230 230 230",
"GradientActiveTitle": "49 54 59",
"GradientInactiveTitle": "40 42 45",
"GrayText": "128 128 128",
"Hilight": "61 174 233",
"HilightText": "255 255 255",
"HotTrackingColor": "61 174 233",
"InactiveBorder": "40 42 45",
"InactiveTitle": "40 42 45",
"InactiveTitleText": "128 128 128",
"InfoText": "230 230 230",
"InfoWindow": "49 54 59",
"Menu": "49 54 59",
"MenuBar": "49 54 59",
"MenuHilight": "61 174 233",
"MenuText": "230 230 230",
"Scrollbar": "49 54 59",
"TitleText": "230 230 230",
"Window": "35 38 41",
"WindowFrame": "49 54 59",
"WindowText": "230 230 230",
}
# Standard light colors (Windows defaults)
LIGHT_COLORS = {
"ActiveBorder": "180 180 180",
"ActiveTitle": "153 180 209",
"AppWorkSpace": "171 171 171",
"Background": "0 0 0",
"ButtonAlternateFace": "0 0 0",
"ButtonDkShadow": "105 105 105",
"ButtonFace": "240 240 240",
"ButtonHilight": "255 255 255",
"ButtonLight": "227 227 227",
"ButtonShadow": "160 160 160",
"ButtonText": "0 0 0",
"GradientActiveTitle": "185 209 234",
"GradientInactiveTitle": "215 228 242",
"GrayText": "109 109 109",
"Hilight": "0 120 215",
"HilightText": "255 255 255",
"HotTrackingColor": "0 102 204",
"InactiveBorder": "244 247 252",
"InactiveTitle": "191 205 219",
"InactiveTitleText": "0 0 0",
"InfoText": "0 0 0",
"InfoWindow": "255 255 225",
"Menu": "240 240 240",
"MenuBar": "240 240 240",
"MenuHilight": "0 120 215",
"MenuText": "0 0 0",
"Scrollbar": "200 200 200",
"TitleText": "0 0 0",
"Window": "255 255 255",
"WindowFrame": "100 100 100",
"WindowText": "0 0 0",
}
# Color scheme presets
COLOR_SCHEMES = {
"dark": DARK_COLORS,
"light": LIGHT_COLORS,
}
# DWM (Desktop Window Manager) settings for dark theme
# AccentColor is ABGR format as dword
DARK_DWM = {
"AccentColor": 0xff3b3b3b, # Dark gray accent
"AccentColorInactive": 0xff2d2d2d, # Darker inactive
"ColorizationAfterglow": 0xc43b3b3b,
"ColorizationColor": 0xc43b3b3b,
"ColorizationColorBalance": 0x59,
"ColorizationGlassAttribute": 0x01,
"ColorPrevalence": 0x01, # Use accent color on title bars
"EnableWindowColorization": 0x01,
}
LIGHT_DWM = {
"AccentColor": 0xffd77800, # Blue accent (Windows default)
"AccentColorInactive": 0xffdbdbdb,
"ColorizationAfterglow": 0xc44f8bcd,
"ColorizationColor": 0xc44f8bcd,
"ColorizationColorBalance": 0x59,
"ColorizationGlassAttribute": 0x01,
"ColorPrevalence": 0x00,
"EnableWindowColorization": 0x01,
}
DWM_SCHEMES = {
"dark": DARK_DWM,
"light": LIGHT_DWM,
}
# Explorer accent settings
DARK_EXPLORER = {
"AccentColorMenu": 0xff3b3b3b,
}
LIGHT_EXPLORER = {
"AccentColorMenu": 0xffd77800,
}
EXPLORER_SCHEMES = {
"dark": DARK_EXPLORER,
"light": LIGHT_EXPLORER,
}
# Vual's own Wine prefix
VUAL_PREFIX = Path.home() / ".local" / "share" / "vual" / "wine_prefix"
def get_vual_prefix() -> Path | None:
"""Return Vual's Wine prefix if it exists."""
if VUAL_PREFIX.is_dir() and (VUAL_PREFIX / "user.reg").is_file():
return VUAL_PREFIX
return None
def get_all_prefixes(steamapps: Path) -> Iterator[Path]:
"""Yield all Proton prefix paths under steamapps/compatdata."""
compatdata = steamapps / "compatdata"
if not compatdata.is_dir():
return
for entry in compatdata.iterdir():
if not entry.is_dir():
continue
# Skip non-numeric (not app IDs)
if not entry.name.isdigit():
continue
pfx = entry / "pfx"
if pfx.is_dir():
yield pfx
def _find_or_create_section(lines: list[str], section_name: str) -> int:
"""Find section index or create it. Returns index of first line after section header.
Wine registry sections can have timestamps: [Section\\Name] 1234567890
We match by prefix to handle this.
"""
target = f"[{section_name}]"
target_lower = target.lower()
for i, line in enumerate(lines):
stripped = line.strip().lower()
# Match exact or with timestamp suffix
if stripped == target_lower or stripped.startswith(target_lower + " "):
return i + 1
# Section not found — add at end
if lines and lines[-1].strip():
lines.append("")
lines.append(target)
lines.append("")
return len(lines) - 1
def _set_string_value(lines: list[str], section_idx: int, key: str, value: str) -> None:
"""Set a string value in registry section. Only modifies string values."""
# Find section end (next section or EOF)
section_end = len(lines)
for i in range(section_idx, len(lines)):
if lines[i].startswith("["):
section_end = i
break
# Look for existing key
key_pattern = f'"{key}"='
for i in range(section_idx, section_end):
if lines[i].startswith(key_pattern):
lines[i] = f'"{key}"="{value}"'
return
# Key not found — insert before section end
insert_at = section_end
# Find last non-empty line in section
for i in range(section_end - 1, section_idx - 1, -1):
if lines[i].strip():
insert_at = i + 1
break
lines.insert(insert_at, f'"{key}"="{value}"')
def _set_dword_value(lines: list[str], section_idx: int, key: str, value: int) -> None:
"""Set a dword value in registry section."""
# Find section end (next section or EOF)
section_end = len(lines)
for i in range(section_idx, len(lines)):
if lines[i].startswith("["):
section_end = i
break
# Format: "Key"=dword:00000000
dword_str = f'"{key}"=dword:{value:08x}'
# Look for existing key
key_pattern = f'"{key}"='
for i in range(section_idx, section_end):
if lines[i].startswith(key_pattern):
lines[i] = dword_str
return
# Key not found — insert before section end
insert_at = section_end
for i in range(section_end - 1, section_idx - 1, -1):
if lines[i].strip():
insert_at = i + 1
break
lines.insert(insert_at, dword_str)
def apply_colors_to_prefix(pfx: Path, colors: dict[str, str], is_dark: bool) -> bool:
"""Apply color scheme to a single prefix.
Modifies:
- [Control Panel\\Colors] — color values as "R G B" strings
- [Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize] — dark mode flags
Args:
pfx: Path to prefix directory (containing user.reg).
colors: Dict of color name -> "R G B" string values.
is_dark: True for dark theme, False for light.
Returns:
True if successful, False otherwise.
"""
user_reg = pfx / "user.reg"
if not user_reg.is_file():
return False
try:
content = user_reg.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
lines = content.splitlines()
# Find or create Colors section
colors_idx = _find_or_create_section(
lines,
"Control Panel\\\\Colors" # Escaped backslash for .reg format
)
# Apply each color
for key, value in colors.items():
_set_string_value(lines, colors_idx, key, value)
# Find or create Personalize section (Windows 10+ dark mode)
personalize_idx = _find_or_create_section(
lines,
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Themes\\\\Personalize"
)
# Set dark mode flags: 0 = dark, 1 = light
light_value = 0 if is_dark else 1
_set_dword_value(lines, personalize_idx, "AppsUseLightTheme", light_value)
_set_dword_value(lines, personalize_idx, "SystemUsesLightTheme", light_value)
# Find or create DWM section for titlebar colors
dwm_idx = _find_or_create_section(
lines,
"Software\\\\Microsoft\\\\Windows\\\\DWM"
)
# Apply DWM settings
dwm_settings = DARK_DWM if is_dark else LIGHT_DWM
for key, value in dwm_settings.items():
_set_dword_value(lines, dwm_idx, key, value)
# Find or create Explorer Accent section
explorer_idx = _find_or_create_section(
lines,
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Explorer\\\\Accent"
)
# Apply Explorer accent settings
explorer_settings = DARK_EXPLORER if is_dark else LIGHT_EXPLORER
for key, value in explorer_settings.items():
_set_dword_value(lines, explorer_idx, key, value)
# Find ThemeManager section and disable msstyles for dark theme
# (Wine visual styles can override our color settings)
theme_mgr_idx = _find_or_create_section(
lines,
"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\ThemeManager"
)
if is_dark:
# Disable visual styles to use our dark colors
_set_string_value(lines, theme_mgr_idx, "ThemeActive", "0")
else:
_set_string_value(lines, theme_mgr_idx, "ThemeActive", "1")
# Write back
try:
user_reg.write_text("\n".join(lines) + "\n", encoding="utf-8")
return True
except OSError:
return False
def apply_theme_to_all(steamapps: Path, theme: str) -> tuple[int, int]:
"""Apply theme to all Proton prefixes and Vual's own prefix.
Args:
steamapps: Path to Steam/steamapps directory.
theme: Theme name ("dark", "light", or "system").
Returns:
Tuple of (success_count, fail_count).
"""
if theme == "system" or theme not in COLOR_SCHEMES:
return (0, 0)
colors = COLOR_SCHEMES[theme]
is_dark = (theme == "dark")
success = 0
failed = 0
# Apply to Steam prefixes
for pfx in get_all_prefixes(steamapps):
if apply_colors_to_prefix(pfx, colors, is_dark):
success += 1
else:
failed += 1
# Apply to Vual's own prefix
vual_pfx = get_vual_prefix()
if vual_pfx:
if apply_colors_to_prefix(vual_pfx, colors, is_dark):
success += 1
else:
failed += 1
return (success, failed)
def get_current_theme(pfx: Path) -> str | None:
"""Detect current theme from prefix colors.
Returns:
"dark", "light", or None if unknown.
"""
user_reg = pfx / "user.reg"
if not user_reg.is_file():
return None
try:
content = user_reg.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
# Check Window background color
match = re.search(r'"Window"="([^"]+)"', content)
if not match:
return None
window_color = match.group(1)
# Dark themes typically have dark Window color
parts = window_color.split()
if len(parts) == 3:
try:
r, g, b = int(parts[0]), int(parts[1]), int(parts[2])
# If all RGB < 100, it's dark
if r < 100 and g < 100 and b < 100:
return "dark"
# If all RGB > 200, it's light
if r > 200 and g > 200 and b > 200:
return "light"
except ValueError:
pass
return None