This commit is contained in:
2026-03-19 05:35:55 +10:00
parent 8f21756df6
commit 659363c474
19 changed files with 1145 additions and 254 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
from pathlib import Path
__version__ = "0.1.0"
__version__ = "0.2.0"
APP_ID = "io.github.vual"
APP_NAME = "Vual"
+1 -1
View File
@@ -7,7 +7,7 @@ import sys
def _ensure_gi_available():
"""Add system site-packages to path if gi is not importable (e.g., in venv)."""
try:
import gi # noqa: F401
__import__("gi")
return
except ImportError:
pass
+14
View File
@@ -39,6 +39,10 @@ class VualApp(Adw.Application):
win.set_default_size(self.config.window_width, self.config.window_height)
win.present()
# Show guide on first run
if not self.config.guide_shown:
win.show_guide()
# ── Startup: register actions ────────────────────────────────
def do_startup(self) -> None:
@@ -67,6 +71,10 @@ class VualApp(Adw.Application):
prefs_action.connect("activate", self._on_preferences)
self.add_action(prefs_action)
guide_action = Gio.SimpleAction.new("guide", None)
guide_action.connect("activate", self._on_guide)
self.add_action(guide_action)
quit_action = Gio.SimpleAction.new("quit", None)
quit_action.connect("activate", lambda *_: self.quit())
self.add_action(quit_action)
@@ -97,3 +105,9 @@ class VualApp(Adw.Application):
win = PreferencesWindow(config=self.config, transient_for=self.props.active_window)
win.present()
# ── Guide ────────────────────────────────────────────────────
def _on_guide(self, _action: Gio.SimpleAction, _param: None) -> None:
if self.props.active_window:
self.props.active_window.show_guide()
+8
View File
@@ -37,6 +37,8 @@ class Config:
ce_language: CE language ("system", "ru_RU").
wine_theme: Wine color theme ("system", "dark", "light").
app_language: Application UI language ("system", "en", "ru").
transparent_window: Enable semi-transparent window background.
guide_shown: Whether the startup guide has been shown.
"""
ce_executable: str = "~/.local/share/vual/cheatengine/cheatengine-x86_64.exe"
@@ -52,6 +54,8 @@ class Config:
ce_language: str = "system"
wine_theme: str = "system"
app_language: str = "system"
transparent_window: bool = False
guide_shown: bool = False
# ════════════════════════════════════════════════════════════════
# Load / Save
@@ -81,6 +85,8 @@ class Config:
ce_language=data.get("ce_language", cls.ce_language),
wine_theme=data.get("wine_theme", cls.wine_theme),
app_language=data.get("app_language", cls.app_language),
transparent_window=data.get("transparent_window", cls.transparent_window),
guide_shown=data.get("guide_shown", cls.guide_shown),
)
except (json.JSONDecodeError, OSError):
pass
@@ -107,6 +113,8 @@ class Config:
"ce_language": self.ce_language,
"wine_theme": self.wine_theme,
"app_language": self.app_language,
"transparent_window": self.transparent_window,
"guide_shown": self.guide_shown,
}
CONFIG_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
return CONFIG_PATH
+260 -70
View File
@@ -1,52 +1,69 @@
/* ═══════════════════════════════════════════════════════════════════════════
Vual — Unified Size System
Base unit: 4px
All sizes are multiples of 4px for consistency
Vual — Adwyra-inspired theme
Minimalist, clean design with soft shadows and accent highlights
═══════════════════════════════════════════════════════════════════════════ */
/* ── Transparent Window ────────────────────────────────────────────────── */
/* ═══════════════════════════════════════════════════════════════════════════
HEADER BAR
═══════════════════════════════════════════════════════════════════════════ */
window.transparent-window {
background-color: alpha(@window_bg_color, 0.82);
}
/* ── Header Bar ───────────────────────────────────────────────────────────── */
/* Header buttons: 32x32 (8 units) */
.header-btn {
min-width: 32px;
min-height: 32px;
padding: 0;
margin: 0;
border-radius: 6px;
border-radius: 10px;
transition: background-color 150ms ease-out, box-shadow 150ms ease-out;
}
.header-btn:hover {
background: alpha(@accent_bg_color, 0.12);
background-color: alpha(@accent_color, 0.08);
box-shadow: 0 2px 6px alpha(black, 0.06);
}
.header-btn:active {
background: alpha(@accent_bg_color, 0.2);
background-color: alpha(@accent_color, 0.16);
box-shadow: none;
}
/* Games counter */
.games-counter {
font-size: 0.85em;
font-weight: 500;
opacity: 0.6;
font-size: 0.84em;
font-weight: 450;
opacity: 0.65;
margin-left: 8px;
}
/* Search entry */
/* ── Search Entry ─────────────────────────────────────────────────────────── */
searchentry {
min-width: 220px;
border-radius: 14px;
padding: 1px 10px;
background-color: alpha(@view_bg_color, 0.3);
border: 1px solid alpha(@borders, 0.1);
box-shadow: 0 1px 3px alpha(black, 0.04);
transition: box-shadow 200ms ease-out, background-color 200ms ease-out,
border-color 200ms ease-out;
}
/* ═══════════════════════════════════════════════════════════════════════════
GRID LAYOUT
═══════════════════════════════════════════════════════════════════════════ */
.vual-grid {
/* Padding handled in Python via set_margin_* */
searchentry:focus {
background-color: @view_bg_color;
border-color: alpha(@accent_color, 0.35);
box-shadow: 0 0 0 3px alpha(@accent_color, 0.15);
}
searchentry image {
opacity: 0.5;
}
searchentry:focus image {
opacity: 0.75;
}
/* ── Grid Layout ──────────────────────────────────────────────────────────── */
flowboxchild {
padding: 0;
margin: 0;
@@ -64,49 +81,47 @@ flowboxchild:selected {
box-shadow: none;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE
═══════════════════════════════════════════════════════════════════════════ */
/* ── Tile ─────────────────────────────────────────────────────────────────── */
/* Tile container: 12px radius */
.vual-tile {
border-radius: 12px;
border-radius: 14px;
background: @card_bg_color;
box-shadow: 0 1px 2px alpha(black, 0.08);
box-shadow: 0 1px 3px alpha(black, 0.10), 0 0 0 1px alpha(black, 0.04);
transition: box-shadow 150ms ease-out;
}
.vual-tile:hover {
box-shadow: 0 2px 8px alpha(black, 0.15);
box-shadow: 0 4px 12px alpha(black, 0.14), 0 0 0 1px alpha(black, 0.05);
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE INFO OVERLAY (bottom)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Tile Info Overlay ────────────────────────────────────────────────────── */
/* Info panel: gradient background */
.vual-tile-info {
padding: 24px 8px 8px 8px;
padding: 20px 8px 8px 8px;
background: linear-gradient(to bottom,
transparent 0%,
alpha(black, 0.6) 50%,
alpha(black, 0.8) 100%);
border-radius: 0 0 12px 12px;
alpha(black, 0.55) 50%,
alpha(black, 0.75) 100%);
border-radius: 0 0 14px 14px;
}
.vual-tile-info label {
color: white;
transition: opacity 150ms ease-out;
}
.vual-tile-info .heading {
font-weight: 600;
font-size: 0.85em;
font-weight: 500;
font-size: 0.84em;
opacity: 0.9;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE BUTTONS (play, CE)
═══════════════════════════════════════════════════════════════════════════ */
.vual-tile:hover .vual-tile-info .heading {
opacity: 1;
}
/* ── Tile Buttons ─────────────────────────────────────────────────────────── */
/* Tile buttons: 28x28 (7 units) */
.tile-btn,
.vual-tile-info button {
min-width: 28px;
@@ -114,28 +129,47 @@ flowboxchild:selected {
padding: 0;
margin: 0;
color: white;
background: alpha(white, 0.12);
border-radius: 6px;
background: alpha(white, 0.08);
border-radius: 8px;
border: none;
transition: background-color 150ms ease-out, box-shadow 150ms ease-out;
}
.tile-btn:hover,
.vual-tile-info button:hover {
background: alpha(white, 0.24);
background: alpha(white, 0.18);
box-shadow: 0 2px 6px alpha(black, 0.12);
}
.tile-btn:active,
.vual-tile-info button:active {
background: alpha(white, 0.32);
background: alpha(white, 0.26);
box-shadow: none;
}
/* ═══════════════════════════════════════════════════════════════════════════
TILE SWITCH
═══════════════════════════════════════════════════════════════════════════ */
/* Popover menus — compact */
popover contents {
padding: 4px;
}
popover modelbutton {
min-height: 22px;
padding: 2px 8px;
font-size: 0.88em;
border-radius: 6px;
transition: background-color 100ms ease-out;
}
popover modelbutton:hover {
background-color: alpha(@accent_color, 0.1);
}
/* ── Tile Switch ──────────────────────────────────────────────────────────── */
.vual-tile-info switch {
background: alpha(white, 0.15);
background: alpha(white, 0.12);
border: none;
transition: background-color 150ms ease-out, box-shadow 150ms ease-out;
}
.vual-tile-info switch:checked {
@@ -146,50 +180,206 @@ flowboxchild:selected {
background: white;
min-width: 16px;
min-height: 16px;
border-radius: 8px;
border-radius: 50%;
box-shadow: 0 1px 3px alpha(black, 0.18);
}
/* ═══════════════════════════════════════════════════════════════════════════
RELOAD BUTTON (top-left corner)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Reload Button ────────────────────────────────────────────────────────── */
/* Reload: 24x24 (6 units) */
.vual-reload-btn {
min-width: 24px;
min-height: 24px;
padding: 0;
margin: 0;
border-radius: 6px;
background: alpha(black, 0.5);
border-radius: 50%;
background: alpha(black, 0.35);
color: white;
opacity: 0;
border: none;
transition: opacity 200ms ease-out, background-color 200ms ease-out;
}
.vual-tile:hover .vual-reload-btn {
opacity: 1;
opacity: 0.7;
}
.vual-reload-btn:hover {
background: @accent_bg_color;
opacity: 1;
background: alpha(@accent_color, 0.75);
}
/* ═══════════════════════════════════════════════════════════════════════════
RUNNING BADGE (top-right corner)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Running Badge ────────────────────────────────────────────────────────── */
/* Badge: 20px total (14px icon + 3px padding each side) */
.vual-badge {
background: @success_bg_color;
min-width: 12px;
min-height: 12px;
background: radial-gradient(circle at 35% 35%, alpha(white, 0.95) 0%, alpha(@accent_color, 0.95) 32%, @accent_color 68%, alpha(@accent_color, 0.88) 100%);
border-radius: 50%;
padding: 3px;
border: 1px solid alpha(white, 0.45);
box-shadow: 0 0 0 1px alpha(@accent_color, 0.16), 0 0 8px alpha(@accent_color, 0.55), 0 0 16px alpha(@accent_color, 0.32), 0 2px 6px alpha(black, 0.22);
}
/* ═══════════════════════════════════════════════════════════════════════════
SKELETON (loading placeholder)
═══════════════════════════════════════════════════════════════════════════ */
/* ── Skeleton ─────────────────────────────────────────────────────────────── */
.skeleton {
background: alpha(@card_shade_color, 0.2);
background: alpha(@card_shade_color, 0.15);
border-radius: 14px;
}
/* ═══════════════════════════════════════════════════════════════════════════
GUIDE PAGE
═══════════════════════════════════════════════════════════════════════════ */
.guide-page {
margin: 0;
}
.guide-hero {
padding: 24px 24px 28px 24px;
border-radius: 24px;
background: linear-gradient(
180deg,
alpha(@accent_color, 0.12) 0%,
alpha(@accent_color, 0.04) 100%
);
border: 1px solid alpha(@accent_color, 0.08);
}
.guide-eyebrow {
font-size: 0.82em;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.7;
}
.guide-title {
font-size: 2.1em;
font-weight: 800;
letter-spacing: -0.03em;
}
.guide-subtitle {
font-size: 1.02em;
opacity: 0.78;
line-height: 1.35;
}
.guide-card {
padding: 20px;
border-radius: 20px;
background: alpha(@view_bg_color, 0.55);
border: 1px solid alpha(@borders, 0.12);
box-shadow: 0 1px 3px alpha(black, 0.04);
}
.guide-card-title {
font-size: 1.08em;
font-weight: 700;
}
.guide-card-subtitle {
opacity: 0.7;
line-height: 1.35;
}
.guide-list-row {
margin-top: 2px;
}
.guide-bullet {
min-width: 12px;
opacity: 0.7;
}
.guide-list-text {
line-height: 1.35;
}
.guide-step {
padding: 10px 0;
}
.guide-step-badge {
border-radius: 999px;
background: alpha(@accent_color, 0.14);
color: @accent_color;
font-weight: 700;
}
.guide-step-title {
font-weight: 650;
}
.guide-step-description {
opacity: 0.72;
line-height: 1.35;
}
/* ═══════════════════════════════════════════════════════════════════════════
PREFERENCES
═══════════════════════════════════════════════════════════════════════════ */
preferencesgroup > box > .header {
margin-top: 4px;
margin-bottom: 4px;
}
preferencesgroup > box > box.header > box.title > label.title {
font-weight: 600;
font-size: 0.84em;
letter-spacing: 0.04em;
opacity: 0.55;
text-transform: uppercase;
}
preferencesgroup > box > .header > label.description {
font-size: 0.8em;
}
/* Boxed lists */
.boxed-list {
border-radius: 12px;
border: 1px solid alpha(@borders, 0.12);
background-color: alpha(@view_bg_color, 0.45);
box-shadow: 0 1px 3px alpha(black, 0.04);
}
row {
border-radius: 10px;
margin: 0 4px;
padding: 0;
min-height: 40px;
transition: background-color 120ms ease-out;
}
row:hover {
background-color: alpha(@accent_color, 0.06);
}
/* ═══════════════════════════════════════════════════════════════════════════
UTILITY CLASSES
═══════════════════════════════════════════════════════════════════════════ */
.dimmed {
opacity: 0.45;
transition: opacity 150ms ease-out;
}
.dimmed:hover {
opacity: 0.9;
}
.success { color: @success_color; }
.warning { color: @warning_color; }
separator {
background-color: alpha(@borders, 0.18);
margin: 4px 0;
min-height: 1px;
}
tooltip {
border-radius: 8px;
padding: 4px 8px;
}
+1 -19
View File
@@ -23,7 +23,6 @@ _LOCALE_DIRS = [
# Global translator
_translator: gettext.GNUTranslations | gettext.NullTranslations | None = None
_current_lang: str = "system"
def _find_locale_dir() -> Path | None:
@@ -47,8 +46,7 @@ def init(lang: str = "system") -> None:
lang: Language code ("system", "en", "ru").
"system" uses system locale.
"""
global _translator, _current_lang
_current_lang = lang
global _translator
# Get system locale
try:
@@ -78,23 +76,8 @@ def init(lang: str = "system") -> None:
_translator = gettext.NullTranslations()
def set_language(lang: str) -> None:
"""Change application language.
Args:
lang: Language code ("system", "en", "ru").
"""
init(lang)
def get_current_language() -> str:
"""Get current language setting."""
return _current_lang
def _(message: str) -> str:
"""Translate a message string."""
global _translator
if _translator is None:
init()
return _translator.gettext(message) if _translator else message
@@ -102,7 +85,6 @@ def _(message: str) -> str:
def ngettext(singular: str, plural: str, n: int) -> str:
"""Translate a plural message."""
global _translator
if _translator is None:
init()
return _translator.ngettext(singular, plural, n) if _translator else (singular if n == 1 else plural)
Binary file not shown.
Binary file not shown.
+76
View File
@@ -0,0 +1,76 @@
"""Cheat Engine table (.CT) storage and per-game binding.
Tables are stored in ~/.local/share/vual/tables/.
Bindings (app_id → filename) are persisted in tables.json alongside the files.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from gi.repository import GLib
TABLES_DIR = Path(GLib.get_user_data_dir()) / "vual" / "tables"
_MAP_FILE = TABLES_DIR / "tables.json"
# ── Internal helpers ─────────────────────────────────────────────
def _load_map() -> dict[str, str]:
if _MAP_FILE.exists():
try:
return json.loads(_MAP_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
return {}
def _save_map(data: dict[str, str]) -> None:
TABLES_DIR.mkdir(parents=True, exist_ok=True)
_MAP_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
# ── Public API ───────────────────────────────────────────────────
def bind(app_id: str, src: str | Path) -> Path:
"""Copy a .CT file into the tables directory and bind it to *app_id*.
Returns the destination path inside TABLES_DIR.
"""
src = Path(src)
TABLES_DIR.mkdir(parents=True, exist_ok=True)
dest = TABLES_DIR / src.name
if dest.resolve() != src.resolve():
shutil.copy2(src, dest)
data = _load_map()
data[app_id] = dest.name
_save_map(data)
return dest
def unbind(app_id: str) -> None:
"""Remove the table binding for *app_id* (file kept on disk)."""
data = _load_map()
if app_id in data:
del data[app_id]
_save_map(data)
def get_table(app_id: str) -> Path | None:
"""Return the full path to the bound .CT file, or None."""
data = _load_map()
name = data.get(app_id)
if not name:
return None
path = TABLES_DIR / name
return path if path.is_file() else None
def get_table_name(app_id: str) -> str | None:
"""Return just the filename of the bound table, or None."""
data = _load_map()
return data.get(app_id)
+102 -25
View File
@@ -19,11 +19,11 @@ gi.require_version("Gdk", "4.0")
gi.require_version("GdkPixbuf", "2.0")
gi.require_version("Pango", "1.0")
from gi.repository import Adw, Gdk, GdkPixbuf, GLib, Gtk, Pango
from gi.repository import Adw, Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango
import requests
from vual import protonhax, steam
from vual import protonhax, steam, tables
from vual.config import TILE_SIZES, Config
from vual.i18n import _
@@ -217,14 +217,39 @@ class GameTile(Gtk.Overlay):
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)
# CE button with table menu
self._btn_ce = Gtk.MenuButton()
self._btn_ce.set_icon_name("open-menu-symbolic")
self._btn_ce.add_css_class("flat")
self._btn_ce.add_css_class("tile-btn")
self._update_ce_tooltip()
ce_menu = Gio.Menu()
ce_menu.append(_("Launch Cheat Engine"), "tile.launch-ce")
ce_menu.append(_("Assign table…"), "tile.assign-table")
ce_menu.append(_("Remove table"), "tile.remove-table")
pop = Gtk.PopoverMenu.new_from_model(ce_menu)
self._btn_ce.set_popover(pop)
# Actions
group = Gio.SimpleActionGroup()
act_launch = Gio.SimpleAction.new("launch-ce", None)
act_launch.connect("activate", lambda *_: self._on_ce_clicked(None))
group.add_action(act_launch)
act_assign = Gio.SimpleAction.new("assign-table", None)
act_assign.connect("activate", lambda *_: self._on_assign_table())
group.add_action(act_assign)
self._act_remove = Gio.SimpleAction.new("remove-table", None)
self._act_remove.connect("activate", lambda *_: self._on_remove_table())
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
group.add_action(self._act_remove)
self.insert_action_group("tile", group)
row.append(self._btn_ce)
# Spacer
spacer = Gtk.Box()
@@ -257,8 +282,7 @@ class GameTile(Gtk.Overlay):
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 = Gtk.Box()
self._badge.add_css_class("vual-badge")
self._badge.set_valign(Gtk.Align.START)
self._badge.set_halign(Gtk.Align.END)
@@ -322,12 +346,35 @@ class GameTile(Gtk.Overlay):
if hasattr(self, "_on_launch"):
self._on_launch(self._app_id, self._name)
def _on_ce_clicked(self, _btn: Gtk.Button) -> None:
def _on_ce_clicked(self, _btn: Gtk.Button | None) -> None:
"""Emit CE signal."""
if hasattr(self, "_on_launch_ce"):
self._on_launch_ce(self._app_id, self._name)
def _on_switch_toggled(self, switch: Gtk.Switch, state: bool) -> bool:
def _on_assign_table(self) -> None:
"""Open file chooser to assign a .CT table."""
if hasattr(self, "_on_pick_table"):
self._on_pick_table(self._app_id)
def _on_remove_table(self) -> None:
"""Remove the bound table."""
tables.unbind(self._app_id)
self._act_remove.set_enabled(False)
self._update_ce_tooltip()
def _update_ce_tooltip(self) -> None:
name = tables.get_table_name(self._app_id)
if name:
self._btn_ce.set_tooltip_text(f"CE: {name}")
else:
self._btn_ce.set_tooltip_text(_("Launch Cheat Engine"))
def mark_table_bound(self) -> None:
"""Refresh table indicator after binding."""
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
self._update_ce_tooltip()
def _on_switch_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
"""Handle activation toggle."""
self._is_active = state
if hasattr(self, "_on_toggle"):
@@ -353,6 +400,9 @@ class GameTile(Gtk.Overlay):
def connect_reload(self, callback) -> None:
self._on_reload = callback
def connect_pick_table(self, callback) -> None:
self._on_pick_table = callback
# ═══════════════════════════════════════════════════════════════════════════════
# MainPage — the main games grid
@@ -473,11 +523,6 @@ class MainPage(Adw.Bin):
for app_id, tile in self._tiles.items():
tile.set_running(app_id in running_ids)
@property
def games_count(self) -> int:
"""Number of loaded games."""
return len(self._tiles)
# ─── Filter & Sort ────────────────────────────────────────────────────────
def _filter_func(self, child: Gtk.FlowBoxChild) -> bool:
@@ -567,6 +612,7 @@ class MainPage(Adw.Bin):
tile.connect_launch_ce(self._on_launch_ce)
tile.connect_toggle(self._on_toggle)
tile.connect_reload(self._on_reload_cover)
tile.connect_pick_table(self._on_pick_table)
self._tiles[app_id] = tile
self._grid.append(tile)
@@ -666,16 +712,50 @@ class MainPage(Adw.Bin):
self._win.show_toast(_("CE not found: %s") % self._cfg.ce_executable)
return
args = None
args: list[str] = []
if self._cfg.ce_language != "system":
args = ["--LANG", self._cfg.ce_language]
args.extend(["--LANG", self._cfg.ce_language])
proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args)
ct = tables.get_table(app_id)
if ct:
args.append(str(ct))
proc = protonhax.run_in_proton(app_id, str(self._cfg.ce_executable_path), args or None)
if proc:
self._win.show_toast(_("CE launched for %s") % name)
else:
self._win.show_toast(_("Failed to launch CE for %s") % name)
def _on_pick_table(self, app_id: str) -> None:
"""Open file chooser to select a .CT table for *app_id*."""
dialog = Gtk.FileDialog()
dialog.set_title(_("Select Cheat Engine table"))
f = Gtk.FileFilter()
f.set_name(_("CE tables (*.CT)"))
f.add_pattern("*.CT")
f.add_pattern("*.ct")
filters = Gio.ListStore.new(Gtk.FileFilter)
filters.append(f)
dialog.set_filters(filters)
dialog.open(self._win, None, self._on_table_chosen, app_id)
def _on_table_chosen(self, dialog: Gtk.FileDialog, result: Gio.AsyncResult, app_id: str) -> None:
try:
gfile = dialog.open_finish(result)
except GLib.Error:
return
if not gfile:
return
path = gfile.get_path()
if not path:
return
tables.bind(app_id, path)
tile = self._tiles.get(app_id)
if tile:
tile.mark_table_bound()
def _on_toggle(self, app_id: str, active: bool) -> None:
"""Handle activation toggle."""
tile = self._tiles.get(app_id)
@@ -811,6 +891,3 @@ class MainPage(Adw.Bin):
if restart:
steam.start_steam()
def schedule_auto_launch(self, app_id: str) -> None:
"""Schedule auto CE launch for a game."""
self._auto_ce = app_id
+16
View File
@@ -29,6 +29,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
**kwargs,
)
self._config = config
self._parent = kwargs.get("transient_for")
self._exclusion_rows: list[Adw.EntryRow] = []
self._release_info: dict | None = None
self._building = True # Prevent saves during initial build
@@ -95,6 +96,15 @@ class PreferencesWindow(Adw.PreferencesWindow):
self._lang_app_row.connect("notify::selected", self._on_app_lang_changed)
theme_group.add(self._lang_app_row)
# Transparent window toggle
self._transparent_row = Adw.SwitchRow(
title=_("Transparent window"),
subtitle=_("Semi-transparent background"),
)
self._transparent_row.set_active(self._config.transparent_window)
self._transparent_row.connect("notify::active", self._on_transparent_changed)
theme_group.add(self._transparent_row)
# Grid group
grid_group = Adw.PreferencesGroup(
title=_("Grid"),
@@ -179,6 +189,12 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Show restart hint
self._show_toast(_("Restart app to apply language"))
def _on_transparent_changed(self, row: Adw.SwitchRow, _pspec) -> None:
self._config.transparent_window = row.get_active()
self._save()
if self._parent:
self._parent.apply_transparency()
def _apply_theme(self) -> None:
style = Adw.StyleManager.get_default()
schemes = {
+207 -5
View File
@@ -31,12 +31,23 @@ class VualWindow(Adw.ApplicationWindow):
self.toast_overlay = Adw.ToastOverlay()
self.set_content(self.toast_overlay)
toolbar_view = Adw.ToolbarView()
self.toast_overlay.set_child(toolbar_view)
# Navigation view for guide/main switching
self._nav_view = Adw.NavigationView()
self.toast_overlay.set_child(self._nav_view)
# Single main page
# Main page
self._main_nav_page = self._build_main_page()
self._nav_view.add(self._main_nav_page)
# Apply transparency from config
self.apply_transparency()
def _build_main_page(self) -> Adw.NavigationPage:
"""Build the main games grid page."""
from vual.ui.main_page import MainPage
toolbar_view = Adw.ToolbarView()
self.main_page = MainPage(config=self.config, window=self)
# Header bar
@@ -102,7 +113,9 @@ class VualWindow(Adw.ApplicationWindow):
toolbar_view.set_content(self.main_page)
def _on_sort_changed(self, action: Gio.SimpleAction, param: GLib.Variant) -> None:
return Adw.NavigationPage(child=toolbar_view, title=APP_NAME, tag="main")
def _on_sort_changed(self, _action: Gio.SimpleAction, param: GLib.Variant) -> None:
sort_by = param.get_string()
self.config.sort_by = sort_by
self.main_page._update_sort()
@@ -117,7 +130,7 @@ class VualWindow(Adw.ApplicationWindow):
menu = Gio.Menu()
menu.append(_("Preferences"), "app.preferences")
menu.append(_("About"), "app.about")
menu.append(_("Quit"), "app.quit")
menu.append(_("Guide"), "app.guide")
return menu
# ── Toast helper ─────────────────────────────────────────────
@@ -126,3 +139,192 @@ class VualWindow(Adw.ApplicationWindow):
toast = Adw.Toast(title=message, timeout=timeout)
self.toast_overlay.add_toast(toast)
return False
# ── Transparency ──────────────────────────────────────────────
def apply_transparency(self) -> None:
"""Apply or remove transparent window CSS class."""
if self.config.transparent_window:
self.add_css_class("transparent-window")
else:
self.remove_css_class("transparent-window")
# ── Guide page ───────────────────────────────────────────────
def show_guide(self) -> None:
"""Show fullscreen guide page."""
guide_page = self._build_guide_page()
self._nav_view.push(guide_page)
def _build_guide_page(self) -> Adw.NavigationPage:
"""Build the guide/onboarding page."""
toolbar_view = Adw.ToolbarView()
header = Adw.HeaderBar()
header.add_css_class("flat")
toolbar_view.add_top_bar(header)
scroll = Gtk.ScrolledWindow(vexpand=True)
toolbar_view.set_content(scroll)
clamp = Adw.Clamp(maximum_size=760)
scroll.set_child(clamp)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
content.add_css_class("guide-page")
content.set_margin_top(28)
content.set_margin_bottom(40)
content.set_margin_start(24)
content.set_margin_end(24)
clamp.set_child(content)
hero = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
hero.add_css_class("guide-hero")
content.append(hero)
eyebrow = Gtk.Label(label=_("Getting Started"), xalign=0)
eyebrow.add_css_class("guide-eyebrow")
hero.append(eyebrow)
title = Gtk.Label(label=_("Set up Vual in a couple of minutes"), xalign=0)
title.add_css_class("guide-title")
title.set_wrap(True)
hero.append(title)
subtitle = Gtk.Label(
label=_("Vual links Steam, Proton and Cheat Engine so you can launch the game first and open CE in the right prefix without manual setup every time."),
xalign=0,
wrap=True,
)
subtitle.add_css_class("guide-subtitle")
hero.append(subtitle)
prep_card = self._build_guide_card(
_("Before you start"),
_("Make sure the base tools are ready before enabling games."),
[
_("Install or download Cheat Engine in Settings"),
_("Check that the Steam path points to the correct library"),
_("Close Steam before changing launch options for many games"),
],
)
content.append(prep_card)
flow_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=14)
flow_card.add_css_class("guide-card")
content.append(flow_card)
flow_title = Gtk.Label(label=_("How Vual works"), xalign=0)
flow_title.add_css_class("guide-card-title")
flow_card.append(flow_title)
flow_subtitle = Gtk.Label(
label=_("The usual workflow is short and always the same."),
xalign=0,
wrap=True,
)
flow_subtitle.add_css_class("guide-card-subtitle")
flow_card.append(flow_subtitle)
steps = [
(_("Pick a game"), _("Find the game in the library and enable the switch on its tile.")),
(_("Start it from Steam"), _("Steam launches the game with protonhax and prepares the correct environment.")),
(_("Open Cheat Engine"), _("Use the tile menu to launch CE directly for that running game.")),
(_("Optional: attach a table"), _("Bind a .CT file once and Vual will pass it to Cheat Engine automatically.")),
]
for index, (step_title, step_desc) in enumerate(steps, start=1):
flow_card.append(self._build_guide_step(index, step_title, step_desc))
tips_card = self._build_guide_card(
_("Good to know"),
_("A few things save time once the basics are configured."),
[
_("Use search in the header to filter large libraries quickly"),
_("The menu button on each tile contains CE actions and table binding"),
_("Window transparency can be toggled in Appearance settings"),
],
)
content.append(tips_card)
actions = Gtk.Box(spacing=12)
actions.set_halign(Gtk.Align.CENTER)
actions.set_margin_top(12)
prefs_btn = Gtk.Button(label=_("Open Settings"))
prefs_btn.add_css_class("pill")
prefs_btn.connect("clicked", self._on_guide_preferences)
actions.append(prefs_btn)
start_btn = Gtk.Button(label=_("Open Library"))
start_btn.add_css_class("suggested-action")
start_btn.add_css_class("pill")
start_btn.connect("clicked", self._on_guide_close)
actions.append(start_btn)
content.append(actions)
return Adw.NavigationPage(child=toolbar_view, title=_("Welcome to Vual"), tag="guide")
def _build_guide_card(self, title: str, subtitle: str, items: list[str]) -> Gtk.Box:
"""Build a guide card with bullet rows."""
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
card.add_css_class("guide-card")
title_label = Gtk.Label(label=title, xalign=0)
title_label.add_css_class("guide-card-title")
card.append(title_label)
subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
subtitle_label.add_css_class("guide-card-subtitle")
card.append(subtitle_label)
for item in items:
row = Gtk.Box(spacing=10)
row.add_css_class("guide-list-row")
bullet = Gtk.Label(label="")
bullet.add_css_class("guide-bullet")
row.append(bullet)
item_label = Gtk.Label(label=item, xalign=0, wrap=True)
item_label.add_css_class("guide-list-text")
row.append(item_label)
card.append(row)
return card
def _build_guide_step(self, index: int, title: str, description: str) -> Gtk.Box:
"""Build a single step row for the guide."""
row = Gtk.Box(spacing=14)
row.add_css_class("guide-step")
badge = Gtk.Label(label=str(index))
badge.add_css_class("guide-step-badge")
badge.set_size_request(34, 34)
badge.set_valign(Gtk.Align.START)
row.append(badge)
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
title_label = Gtk.Label(label=title, xalign=0)
title_label.add_css_class("guide-step-title")
text_box.append(title_label)
desc_label = Gtk.Label(label=description, xalign=0, wrap=True)
desc_label.add_css_class("guide-step-description")
text_box.append(desc_label)
row.append(text_box)
return row
def _on_guide_close(self, _btn: Gtk.Button) -> None:
"""Close guide and mark as shown."""
self.config.guide_shown = True
self.config.save()
self._nav_view.pop()
def _on_guide_preferences(self, _btn: Gtk.Button) -> None:
"""Open preferences from the guide."""
self._on_guide_close(_btn)
app = self.get_application()
if app is not None:
app.activate_action("preferences", None)
-37
View File
@@ -367,40 +367,3 @@ def apply_theme_to_all(steamapps: Path, theme: str) -> tuple[int, int]:
return (success, failed)
def get_current_theme(pfx: Path) -> str | None:
"""Detect current theme from prefix colors.
Returns:
"dark", "light", or None if unknown.
"""
user_reg = pfx / "user.reg"
if not user_reg.is_file():
return None
try:
content = user_reg.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
# Check Window background color
match = re.search(r'"Window"="([^"]+)"', content)
if not match:
return None
window_color = match.group(1)
# Dark themes typically have dark Window color
parts = window_color.split()
if len(parts) == 3:
try:
r, g, b = int(parts[0]), int(parts[1]), int(parts[2])
# If all RGB < 100, it's dark
if r < 100 and g < 100 and b < 100:
return "dark"
# If all RGB > 200, it's light
if r > 200 and g > 200 and b > 200:
return "light"
except ValueError:
pass
return None