From 659363c4749cb46a7d4af1f7b0c0e5b0266245e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= <6terio9@vk.com> Date: Thu, 19 Mar 2026 05:35:55 +1000 Subject: [PATCH] v0.2.0 --- README.md | 145 +++++++---- data/io.github.vual.metainfo.xml | 115 ++++++--- po/en.po | 124 +++++++++- po/ru.po | 130 +++++++++- po/vual.pot | 39 ++- pyproject.toml | 2 +- src/vual/__init__.py | 2 +- src/vual/__main__.py | 2 +- src/vual/app.py | 14 ++ src/vual/config.py | 8 + src/vual/data/style.css | 330 +++++++++++++++++++------ src/vual/i18n.py | 20 +- src/vual/locale/en/LC_MESSAGES/vual.mo | Bin 8553 -> 12259 bytes src/vual/locale/ru/LC_MESSAGES/vual.mo | Bin 10917 -> 16089 bytes src/vual/tables.py | 76 ++++++ src/vual/ui/main_page.py | 127 ++++++++-- src/vual/ui/preferences.py | 16 ++ src/vual/window.py | 212 +++++++++++++++- src/vual/wine_theme.py | 37 --- 19 files changed, 1145 insertions(+), 254 deletions(-) create mode 100644 src/vual/tables.py diff --git a/README.md b/README.md index cef39c4..62460a0 100644 --- a/README.md +++ b/README.md @@ -1,98 +1,137 @@
-
+
- GTK4-приложение для запуска Cheat Engine в Steam-играх через Proton + Запуск Cheat Engine для Steam-игр через Proton
-## Что это -Vual показывает библиотеку установленных Steam-игр в виде сетки с обложками и позволяет в один клик запустить игру, включить для неё [protonhax](https://github.com/jcnils/protonhax) и открыть Cheat Engine прямо внутри Proton-контекста запущенной игры. +## О проекте -Приложение самостоятельно скачивает и распаковывает Cheat Engine, управляет protonhax, настраивает тему Wine для всех Proton-префиксов и поддерживает русский язык — как собственного интерфейса, так и Cheat Engine. +Vual — приложение для запуска Cheat Engine в Steam-играх, работающих через Proton. +Показывает библиотеку Steam, управляет protonhax и launch options, скачивает и +распаковывает CE автоматически, запускает его в нужном Wine-префиксе — всё из одного +окна. ## Возможности -| | | -|---|---| -| 🎮 **Библиотека** | Сетка игр с обложками из Steam CDN, поиск, сортировка по имени и статусу | -| ▶️ **Запуск** | Запуск игры через Steam URI, автоматическое подключение CE к запущенной Proton-игре | -| 🔧 **protonhax** | Переключатель launch options для каждой игры и массовое включение/отключение | -| 📦 **Cheat Engine** | Скачивание с cheatengine.org, распаковка через Wine из Proton, тестовый запуск | -| 🎨 **Тема Wine** | Тёмная и светлая тема для всех Proton-префиксов разом | -| 🌐 **Локализация** | Язык приложения (EN/RU), язык CE, установка русской локализации CE | -| ⚙️ **Настройки** | Путь к Steam, шаблон LaunchOptions, regex-исключения, размер плитки | +- **Сетка игр** — обложки из Steam CDN, поиск в реальном времени, сортировка по имени или статусу +- **Запуск одним кликом** — игра стартует через Steam, CE подключается к нужному Proton-префиксу +- **Таблицы CE** — привязка .CT-файла к игре, автоматическое открытие вместе с CE +- **protonhax** — автоматическая установка, переключатель launch options для каждой игры, массовое вкл/выкл +- **Установка CE** — скачивание с cheatengine.org, распаковка через Wine из Proton +- **Тема Wine** — тёмная или светлая тема для всех Proton-префиксов разом +- **Локализация** — интерфейс EN/RU, язык CE, установка русской локализации из офиц. репозитория CE +- **Прозрачность окна** — полупрозрачный фон в стиле frosted glass +- **Гайд** — встроенная страница быстрого старта при первом запуске и из меню ## Установка +### Stapler + +``` +stplr repo add lumenara https://github.com/Cheviiot/Lumenara.git +stplr refresh && stplr install vual +``` + ### Из исходников -```bash +``` git clone https://github.com/Cheviiot/vual.git cd vual ./vual ``` -### [Stapler](https://stplr.dev/) - -```bash -stplr repo add lumenara https://github.com/Cheviiot/Lumenara -stplr refresh && stplr install vual -``` -- Vual is a GTK4/libadwaita application that lets you browse your Steam - library, toggle protonhax launch options per game, and run Cheat Engine - inside the Proton context of a running game — all from a single window. + Vual lets you run Cheat Engine inside any Steam game that uses Proton. + It reads your Steam library, manages protonhax launch options, downloads + and extracts Cheat Engine automatically, and launches CE in the correct + Wine prefix — all from one compact window.
- Vual — приложение на GTK4/libadwaita, которое показывает библиотеку Steam, - позволяет включать protonhax для каждой игры и запускать Cheat Engine - внутри Proton-контекста запущенной игры — из одного окна. + Vual позволяет запускать Cheat Engine в любой Steam-игре, работающей + через Proton. Приложение читает библиотеку Steam, управляет launch + options для protonhax, автоматически скачивает и распаковывает Cheat + Engine и запускает CE в нужном Wine-префиксе — всё из одного окна.
+ +Key features:
+Основные возможности:
CE table binding, Wine theme manager, built-in guide, window transparency, Adwyra-inspired theme, Russian CE locale installer.
+Привязка CE-таблиц, менеджер тем Wine, встроенный гайд, прозрачность окна, тема Adwyra, установка русской локализации CE.
+First release.
-Первый выпуск.
+First release — game grid, protonhax management, CE download and launch.
+Первый выпуск — сетка игр, управление protonhax, скачивание и запуск CE.
{!^R=U(uD_(MqTNS(HSJ@x>{=Oa4uJ0q_kEyzl#uT( zq1{N!t}k%2n)V#+DcTtAqcmNY(MD;H(oUl3`h@)@kAi3Fenx*Zm>Y=`cV#pXZ;Yx# zh0L9x5Yv}r`b`V#JqtV z#};#?!CG!GJdXRLYB|cWe08N ^YiClBiJC5sQc7Ts2x+8Z4CJC|9eNM<6WE z!JE6?qRmkWaS7SFT1(vGrBQ!*W2soq^+RD&gZ(5Ks4-iQreH>+NmLuvQT@4UtZBtu zt&|^PJ%mRyY|~P?O0QPx?+pXSUJ}iGFKo EXMtv!;_@jx<`YO z_EimH+J`IgCu;@FpnWunt8gdrW`fc4{ILHUt*yqn5t+jfG)7FW%H+aB@n0L2ShA7o z5Xz7NwyYa1BiBR%)PsDvQi=1`s95N$ G4CAN&$CArG5TN3xx2KhUXs}=D)>@zA? z#N(1eBA>`G!uJ~YlC+%3ynmrv5^T6}BhfS{YK{)%@ZJ7KKZrS7P1npd>nG7br92Xq z2L=lH0 ksvoHJxuqpz#)I(^ZaY|6T0(bu zg$qcHlA&CGc_V=fnGTr@i-m!zTUx0|0K}cm(lWZ6L5#{GihtU$fy_uV_q4~x=+aI3 zVoj7LZt12tUo$6dx%Ht=P8)BEXYrs~$>poi_QICWMagKKFAQw <`9OY-6C@G8CdfR4fy~3O8DWZF%hDPDBI>f#)hkN;#ET5r(Gw zU~DGB*py(y$;Lu8KSVGkZsLpxyW1_3@%0yy(PEAWnfbOxlc*U)miY&G9MKtTT2>_b zj+85-Lxp4{iU$Vd&g?67u~EW=^2U~NL|7m}#YJ3+K;V{@%a$C5OC+6Tl>$4O%YA@V zKE#(R!--q&Lt=)}ni`2ChcmSbCrrYF8sV$mY!S-ZjIox 0FO7_>HGt+j!i$t{RVE zOjTkyRE=8ZAIfd&AkI>FUyKBe$nNl1o;@Q`9#~Nr9IE=iv&p)Z;n`AfvN>UUx1v@5 ztsMenCC>G4Zb_zXkArt%!7CTJmAPS}4ONjj2jvns<#N%NOn#e?Tg-< @MpRolY#`^WBDbniAe#|^i{3W! z (!gJWz>~p$tjS7&5)VcvDscTIwDmDJlRbJPYh{C;bHoq^}7@h8cioOR|H?PzRb6 zS!1 +Qy z8Yz^}TB0ewhFa@$oeMZSYVMieJlo=T&X?zT6(g)1Q3)_}?mRQRwPk8$i^-~lE%h3u z8dIs8byUCg7y2!r=T+hOCylXq6GkzKM=Sfh`H>77IYuF@>DNXhX;^abld|eAE^R1O z$|VJS#O@t;U5E{CooWWZw_8WXVpc8ct5x-yz`t0oVATMxF4Y{?CXFeJkhtwg%iB?i zpteOVv3CO!$AFTL9Ayakcp(-yl9hZC#D%-H0L!<(W@QCcAMGW@Ix9vf-`u)}qVt7&q!=wdE>S1g$M49lnU%6kY~OZ zXdqUh#scS|wNPx~g0mNqVEwsCAPC7hD?o+XXXA zGv@XzSTHv_|NKa|J?~sFcW$&W>WP?6|MCU%vPYLKc)xCKMbBk(d!n0f_Dh)4GmABI zSu=0erPKAvBt4WqQ{Up!2h;KT*7QhvEInG^A&k zHkNHkCtP|rd$7?h9CsLI#~5L+@%kOMBXqJbJ>b$O(}VS~^oS&+^I6RrJ1gDqsXhdo z$I@p#EjoHHCmfQXSh0&0+n@`Pcxmd`^ts@2UB>n}J060^tzum|kh~ 5KGO;u9J zcJ8)2; G+$qliz;O zB=TLl&y*uoW_H)MwTw%mXO{OqZ1|Ae>4-PCOihTTeVwdI%t-m>P~OnOP<;pdB1wta zob8BDqIi+F&$c7$x3(?z`^hk$YLytn!I#<&nH|+{=P(Q;JIohma10OTtTHt-V=T|R zs7w_%C8SM2;a!SJhrEUfQ&5i8Y;UKDz0A+<#`&LuXPN4bh8HP(w2mvd&} Nl#Hj=ZF|R2uei2nSq-YGXrOe z%n#o&S3@S+5{EkO+1@ca&sF3?jt!$hR*fC%V_w_OKoJh)(#Om~6gd ml}SKn |Tlf+9Y)I4;2;ewq6*`gm;zb?s-FV>YS-WITc7@JI_S z$bbx?Bf}?Bl5(3Mm8IJT8j_tfemuE8szZEG=Ik+iR1PQ^n9Yfs1NG0yJbdh682cA} zK1Hw?p|*)!wM$6&ekVE25q*H2D!)%&Dy6qOyFawm=`trFakU=qwYCSIGO-vdvT6;A zo1?xAp)MPv{>lo0sh_u2M2(NUDGp|P5wh6NTg)+gg9uSKt27!hS*0M6_I4j{%|+XG z$n3ClXR}}>Wj0oYfmb4g9afk&$0#kWqC@O$f%Q1kBm*PH5|@NMffQt{s&BCBIz ;<<=_h1mgs!B#dK^qB#b>462`P+ zd%2{tc{f?W(z`b=lNie1=F+d!Z$ZvVu8JZ?7gQ=nSz?w~Ssw`?P*nwwSQu!O^^s<< zPEiV1B|7rXp>oRC)|O{1T-%^(W~&>OuE^Hfj%cI3U3w2&S&F((d-~${DP!A5Isc|X zY+yl~!d*JWAnVOI@H&v_bsVFZwA8B^Wgrk^T8ZSaMAH^D%>=}T2^P&rO(vm9J4(?C zXV^xwfHGt2q}=4N2SIWg^oO^YMqQ02*#X0`WHR3Pq^}|#VkLHhHD?h+6!y*jVWSbM z+vf2?i2XE!I`K%UMKzm590;5=q+;9NHgv+l@S^~kO&yD+m;#yEGMhrIx6pKa))d>f zNpz~`ez8@42zLwQg|e!SsW*Jr>}Q32=*fOMn31%2ar>i2+#(EXo8at?H|#%XcIQt9 zL1>v>+6%=WvU!^|cdjCjJPylx&pF0>Q9EIQ?#36Nj-My|yCP)a*jb=9hKVi>ohkz+ zRz!QZwi-)`sjW*rzfH+jF~NtN;2npIdilCNaq0cgi4A+hW3mD_yG=PPvM7Cl$udwl zINqYEXFrfqx;<)U7=ktPb~N8a8ecf9oWQImw2xv$7OA8y3MMR{gjFBoty?w2I5hIw zX%???qZ!=&>qaopF{Jv4P4^Bc-}Jw%gse3+)mE6YjOZ~bz=TQ^5<>|;sYsSvgdMUu zodJn5h^)!}Pnk zgz =YmsqkOowdU%So zX_I-ufe9VR3a@S--F#W8b1atlZvn>;Dt6>MAqaL1_&6@7d{)_o-+}m{i8mkEZ+yOL zI=|A0*P^^36S`k*m0)^Ua<*?a9nO#?2? _44Ru^ z8R4ZnyeUjk#ar}x*yN^kP41(VZFDVOiH0enl1y86ccl3hq0Oi|O|q>vH|{$Pv0WJ* z$*prTvN(hqt@a}9$e8c)>`JUw4_gG{&CRr}LOr}4kPJF(-9&D`k4Spb7}-*G%|{N_ z;W!-&Br2Eg<9O &(eGiY5d5S zJ|YI0C5H7nVCrsUA$wqsY?UxIdnSiRv@^$(BAi)*{~M}Y<$qtk=-v9LqSEhu&PV4? zsqYG>1yj?mEj19a$-iy47Cnum%TozkA8Z@Bg9G?JD^qRxro|M@A3Ky 1W#oLu zm25G5p8!Zzt*-=hJ3gDrz4v6XteuvI)nwvAUF`&PI0M;Q27+x9xB1N02 5Lb3<%L8_7N=3c@G^@5KfJDV?|)CCsBpx0g^n4oVN`XXiS+4#v9BW?W_*-ka( zn_NO4z(S>}%EbQwg9&9c`qw)vL4uDH|NL{oo7 7cBeL ryZy&R^I82mo<2Hxh5Zt4g*^1wM+))O9G&VE`>kBcG^M{%xN!du_A%qy delta 3438 zcmY+^c~DkW7{~F$rYI-~uP6!#A|Rp&xFDzq=DsVg o8`U{ z=D*)i_7YLVQGa8;#$oNaP^JYN6Nu9>01L1^7Ghgmfk9Y>dd2Phz8zz@-{ dl?2GzkujK;gDL<3kZEp0DU zyHv+4?8^NpjK>A2iPocAOR<{@eVD_j85~1(_!Y88<~C};r>J%=Uee5ap=O$lTH18v z&rIQ>6)Qv~RE|1BYfuB%q59j^iS^ga-{pa3=)q(>iQ#w$wS-Sl?fl8E3x;D)OhFAi z1(om$)J%7vR_-0=`8ibLcTrpQCu&QY!&rZ1>S8o?5Qq997kzOKYTyD?=B1d3Ymh$7 zTc{3?p%QIGt;|_e`*Wxjy5ij5!0y~XK&?!m+to7jFjU8p$ZsYWwYM`-Gps~)v>W*| zuX9lcN1gjmP>G*ME%hx_0>7aWYC^5>Q&hqMtiQ%_2T{>XdZ3ml1J!UOs-y9!nax1G zxDb_42`YhlRQsJs-)1lB@V$?5cpUZKPZ){UPy;_kCge7Or0vIpP~?0W7b^1v#{tOp zn>^G2`B>~@%raC0-*F)5%UnUN&;wLIk5T_00yq$QKLOP)6Eko!=IHycbAC95%Iq@g zaNWmP3?^+o?}ysEJme>{0Cjd&J8nkJcn|i$ _Ol~7kcsZ1P#I)7#rm0`FU zlkhZZz >j}ps89p1^<59gz{dIze%gXmVK zja1a|7OKNXsKeKRixNpe&3FXPz_~aKkD!+N9xBl$?2Vn6ZZ>A460JeCYea3~In+vA zk750jsWdwclX$9*N>LrvqrQp{F$*u^2n>pCndwAK (U|3u+7AM8+~_orbq@BKLnd&qs1Vv{H+aN2V6pC36V1)Ssdf zzmJ13l>fV_I0jR&1~tLMZYs*?Thta@$HDjjl~6qY#s*?Gs^bdO_k0lb``f5QPGA9E z#{QVbLDA=1i0Z!-wK6ZD2HuL=5_ba?&Gbvm!<(q3j3<31l#8QrD(c1kI0?T%ZAlQz zrFNr{SItb+=er(t7`LHj{23~dd#IKA3)v#KiJ;eD9;9FZ4nZZ9 SWM`XoaH=ggR7kS5?oK{Q`QP|M7%}Gd&h4_<&~CarVzDnn~3d1Yne}_oY+NZ zwl5P)2^H @6F)4kdOF`5J#Em8ryHqCL@C`ccVrZnofb zr#=^biI<281n0=q5p9V;ViDm-yg;bvEWAqSY%CyJ%L*!cHGdrj4zn3Wj3+9HAcF1m z?(?(w9HBC%1@D& B&vwkqC= Bp4MW|0pWdt$ATQlYv+(Ybk?nOP}9VDS7c4<-hk}8w8xT2=I z $VGuq1FgS{vaC;~- a&HkQQZm$f kef|Y7|77g| diff --git a/src/vual/tables.py b/src/vual/tables.py new file mode 100644 index 0000000..5675ecd --- /dev/null +++ b/src/vual/tables.py @@ -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) diff --git a/src/vual/ui/main_page.py b/src/vual/ui/main_page.py index 15eb406..c5a6e28 100644 --- a/src/vual/ui/main_page.py +++ b/src/vual/ui/main_page.py @@ -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 diff --git a/src/vual/ui/preferences.py b/src/vual/ui/preferences.py index 2122854..0e1e3c5 100644 --- a/src/vual/ui/preferences.py +++ b/src/vual/ui/preferences.py @@ -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 = { diff --git a/src/vual/window.py b/src/vual/window.py index fe5e0c0..211fcce 100644 --- a/src/vual/window.py +++ b/src/vual/window.py @@ -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) diff --git a/src/vual/wine_theme.py b/src/vual/wine_theme.py index 08f4823..4a148eb 100644 --- a/src/vual/wine_theme.py +++ b/src/vual/wine_theme.py @@ -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