mirror of
https://github.com/Cheviiot/Vual.git
synced 2026-08-03 15:57:25 +00:00
v0.3.0: split modules, screenshots, localization, icons, version check
This commit is contained in:
+12
-384
@@ -1,31 +1,24 @@
|
||||
"""Main games grid page.
|
||||
|
||||
Clean rewrite using Gtk.FlowBox + simple Gtk.Overlay tiles.
|
||||
"""
|
||||
"""Main games grid page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("GdkPixbuf", "2.0")
|
||||
gi.require_version("Pango", "1.0")
|
||||
|
||||
from gi.repository import Adw, Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango
|
||||
from gi.repository import Adw, Gio, GLib, Gtk # noqa: E402
|
||||
|
||||
import requests
|
||||
|
||||
from vual import protonhax, steam, tables
|
||||
from vual.config import TILE_SIZES, Config
|
||||
from vual.i18n import _
|
||||
from vual import protonhax, steam, tables # noqa: E402
|
||||
from vual.config import TILE_SIZES, Config # noqa: E402
|
||||
from vual.i18n import _ # noqa: E402
|
||||
from vual.ui.covers import cache_path, download_cover, load_pixbuf # noqa: E402
|
||||
from vual.ui.game_tile import COVER_RATIO, GameTile # noqa: E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vual.window import VualWindow
|
||||
@@ -35,373 +28,8 @@ if TYPE_CHECKING:
|
||||
# Constants
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
COVER_RATIO = 1.5 # Height = Width * 1.5 (portrait)
|
||||
GRID_SPACING = 12 # Spacing between tiles
|
||||
GRID_MARGIN = 16 # Margin around grid
|
||||
|
||||
# Steam cover URLs
|
||||
COVER_URL = "https://steamcdn-a.akamaihd.net/steam/apps/{}/library_600x900_2x.jpg"
|
||||
COVER_FALLBACK = "https://steamcdn-a.akamaihd.net/steam/apps/{}/header.jpg"
|
||||
|
||||
# Cache directory
|
||||
CACHE_DIR = Path(GLib.get_user_cache_dir()) / "vual" / "covers"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Cover loading utilities
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _cache_path(app_id: str) -> Path:
|
||||
"""Get cache path for cover image."""
|
||||
return CACHE_DIR / f"{app_id}.jpg"
|
||||
|
||||
|
||||
def _download_cover(app_id: str) -> Path | None:
|
||||
"""Download cover from Steam CDN. Returns cache path or None."""
|
||||
cache = _cache_path(app_id)
|
||||
if cache.exists():
|
||||
return cache
|
||||
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url_template in (COVER_URL, COVER_FALLBACK):
|
||||
url = url_template.format(app_id)
|
||||
try:
|
||||
resp = requests.get(url, headers={"User-Agent": "Vual/1.0"}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
cache.write_bytes(resp.content)
|
||||
return cache
|
||||
except requests.RequestException:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _load_pixbuf(path: Path, width: int, height: int) -> GdkPixbuf.Pixbuf | None:
|
||||
"""Load and scale pixbuf from file.
|
||||
|
||||
Handles landscape images by compositing them onto a blurred background.
|
||||
"""
|
||||
try:
|
||||
original = GdkPixbuf.Pixbuf.new_from_file(str(path))
|
||||
except GLib.Error:
|
||||
return None
|
||||
|
||||
ow, oh = original.get_width(), original.get_height()
|
||||
if ow < 1 or oh < 1:
|
||||
return None
|
||||
|
||||
aspect = ow / oh
|
||||
|
||||
# If image is landscape (wider than 4:3), create composite with blur
|
||||
if aspect > 1.33:
|
||||
return _create_landscape_composite(original, ow, oh, width, height)
|
||||
|
||||
# Portrait or square: scale to fill
|
||||
return original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
|
||||
def _create_landscape_composite(
|
||||
original: GdkPixbuf.Pixbuf,
|
||||
ow: int, oh: int,
|
||||
width: int, height: int
|
||||
) -> GdkPixbuf.Pixbuf:
|
||||
"""Create a portrait tile from a landscape image.
|
||||
|
||||
Composites the original image centered on a scaled/blurred background.
|
||||
"""
|
||||
# Create background: scale original to fill tile (will be cropped/stretched)
|
||||
bg = original.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
# Apply simple darkening to background (simulates blur effect)
|
||||
# We darken by compositing with a semi-transparent black
|
||||
dark = GdkPixbuf.Pixbuf.new(
|
||||
GdkPixbuf.Colorspace.RGB, True, 8, width, height
|
||||
)
|
||||
dark.fill(0x00000099) # Black with ~60% opacity
|
||||
dark.composite(
|
||||
bg, 0, 0, width, height,
|
||||
0, 0, 1.0, 1.0,
|
||||
GdkPixbuf.InterpType.NEAREST, 180
|
||||
)
|
||||
|
||||
# Scale original to fit width while preserving aspect ratio
|
||||
scale = width / ow
|
||||
scaled_w = width
|
||||
scaled_h = int(oh * scale)
|
||||
|
||||
scaled = original.scale_simple(scaled_w, scaled_h, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
# Center vertically
|
||||
y_offset = (height - scaled_h) // 2
|
||||
|
||||
# Composite scaled image onto darkened background
|
||||
scaled.composite(
|
||||
bg,
|
||||
0, y_offset, # dest x, y
|
||||
scaled_w, scaled_h, # dest width, height
|
||||
0, y_offset, # offset x, y
|
||||
1.0, 1.0, # scale x, y
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
255 # full opacity
|
||||
)
|
||||
|
||||
return bg
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# GameTile — single game tile widget
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class GameTile(Gtk.Overlay):
|
||||
"""A single game tile with cover image and controls overlay."""
|
||||
|
||||
__gtype_name__ = "VualGameTile"
|
||||
|
||||
def __init__(self, app_id: str, name: str, is_active: bool, tile_width: int):
|
||||
super().__init__()
|
||||
|
||||
self._app_id = app_id
|
||||
self._name = name
|
||||
self._is_active = is_active
|
||||
self._is_running = False
|
||||
self._width = tile_width
|
||||
self._height = int(tile_width * COVER_RATIO)
|
||||
|
||||
# Fixed size container
|
||||
self.set_size_request(self._width, self._height)
|
||||
self.add_css_class("vual-tile")
|
||||
self.set_overflow(Gtk.Overflow.HIDDEN)
|
||||
|
||||
# Cover image (fills the tile)
|
||||
self._picture = Gtk.Picture()
|
||||
self._picture.set_content_fit(Gtk.ContentFit.COVER)
|
||||
self._picture.set_size_request(self._width, self._height)
|
||||
self._picture.add_css_class("skeleton")
|
||||
self.set_child(self._picture)
|
||||
|
||||
# Bottom info overlay
|
||||
self._build_info_box()
|
||||
|
||||
# Top-left reload button
|
||||
self._build_reload_button()
|
||||
|
||||
# Top-right running badge
|
||||
self._build_running_badge()
|
||||
|
||||
def _build_info_box(self) -> None:
|
||||
"""Create bottom info panel with name, buttons, and switch."""
|
||||
info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
info.add_css_class("vual-tile-info")
|
||||
info.set_valign(Gtk.Align.END)
|
||||
info.set_halign(Gtk.Align.FILL)
|
||||
|
||||
# Game name
|
||||
label = Gtk.Label(label=self._name)
|
||||
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||
label.set_max_width_chars(15)
|
||||
label.set_halign(Gtk.Align.START)
|
||||
label.add_css_class("heading")
|
||||
info.append(label)
|
||||
|
||||
# Controls row: [Play] [CE] ---- [Switch]
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
row.set_halign(Gtk.Align.FILL)
|
||||
|
||||
# Play button
|
||||
btn_play = Gtk.Button()
|
||||
btn_play.set_icon_name("media-playback-start-symbolic")
|
||||
btn_play.add_css_class("flat")
|
||||
btn_play.add_css_class("tile-btn")
|
||||
btn_play.set_tooltip_text(_("Launch game"))
|
||||
btn_play.connect("clicked", self._on_play_clicked)
|
||||
row.append(btn_play)
|
||||
|
||||
# CE button with table menu
|
||||
self._btn_ce = Gtk.MenuButton()
|
||||
self._btn_ce.set_icon_name("open-menu-symbolic")
|
||||
self._btn_ce.add_css_class("flat")
|
||||
self._btn_ce.add_css_class("tile-btn")
|
||||
self._update_ce_tooltip()
|
||||
|
||||
ce_menu = Gio.Menu()
|
||||
ce_menu.append(_("Launch Cheat Engine"), "tile.launch-ce")
|
||||
ce_menu.append(_("Assign table…"), "tile.assign-table")
|
||||
ce_menu.append(_("Remove table"), "tile.remove-table")
|
||||
|
||||
pop = Gtk.PopoverMenu.new_from_model(ce_menu)
|
||||
self._btn_ce.set_popover(pop)
|
||||
|
||||
# Actions
|
||||
group = Gio.SimpleActionGroup()
|
||||
|
||||
act_launch = Gio.SimpleAction.new("launch-ce", None)
|
||||
act_launch.connect("activate", lambda *_: self._on_ce_clicked(None))
|
||||
group.add_action(act_launch)
|
||||
|
||||
act_assign = Gio.SimpleAction.new("assign-table", None)
|
||||
act_assign.connect("activate", lambda *_: self._on_assign_table())
|
||||
group.add_action(act_assign)
|
||||
|
||||
self._act_remove = Gio.SimpleAction.new("remove-table", None)
|
||||
self._act_remove.connect("activate", lambda *_: self._on_remove_table())
|
||||
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
|
||||
group.add_action(self._act_remove)
|
||||
|
||||
self.insert_action_group("tile", group)
|
||||
row.append(self._btn_ce)
|
||||
|
||||
# Spacer
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
row.append(spacer)
|
||||
|
||||
# Active switch
|
||||
self._switch = Gtk.Switch()
|
||||
self._switch.set_valign(Gtk.Align.CENTER)
|
||||
self._switch.set_active(self._is_active)
|
||||
self._switch.connect("state-set", self._on_switch_toggled)
|
||||
row.append(self._switch)
|
||||
|
||||
info.append(row)
|
||||
self.add_overlay(info)
|
||||
|
||||
def _build_reload_button(self) -> None:
|
||||
"""Create reload button in top-left corner."""
|
||||
btn = Gtk.Button()
|
||||
btn.set_icon_name("view-refresh-symbolic")
|
||||
btn.add_css_class("flat")
|
||||
btn.add_css_class("vual-reload-btn")
|
||||
btn.set_valign(Gtk.Align.START)
|
||||
btn.set_halign(Gtk.Align.START)
|
||||
btn.set_margin_top(6)
|
||||
btn.set_margin_start(6)
|
||||
btn.set_tooltip_text(_("Refresh cover"))
|
||||
btn.connect("clicked", self._on_reload_clicked)
|
||||
self.add_overlay(btn)
|
||||
|
||||
def _build_running_badge(self) -> None:
|
||||
"""Create running badge in top-right corner."""
|
||||
self._badge = Gtk.Box()
|
||||
self._badge.add_css_class("vual-badge")
|
||||
self._badge.set_valign(Gtk.Align.START)
|
||||
self._badge.set_halign(Gtk.Align.END)
|
||||
self._badge.set_margin_top(6)
|
||||
self._badge.set_margin_end(6)
|
||||
self._badge.set_visible(False)
|
||||
self.add_overlay(self._badge)
|
||||
|
||||
# ─── Properties ───────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def app_id(self) -> str:
|
||||
return self._app_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self._is_active
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
|
||||
# ─── Setters ──────────────────────────────────────────────────────────────
|
||||
|
||||
def set_active_silent(self, active: bool) -> None:
|
||||
"""Set switch without triggering callback."""
|
||||
self._is_active = active
|
||||
self._switch.handler_block_by_func(self._on_switch_toggled)
|
||||
self._switch.set_active(active)
|
||||
self._switch.handler_unblock_by_func(self._on_switch_toggled)
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
"""Update running badge visibility."""
|
||||
self._is_running = running
|
||||
self._badge.set_visible(running)
|
||||
|
||||
def set_cover(self, pixbuf: GdkPixbuf.Pixbuf | None) -> None:
|
||||
"""Set cover image from pixbuf."""
|
||||
self._picture.remove_css_class("skeleton")
|
||||
if pixbuf:
|
||||
texture = Gdk.Texture.new_for_pixbuf(pixbuf)
|
||||
self._picture.set_paintable(texture)
|
||||
else:
|
||||
self._picture.set_paintable(None)
|
||||
|
||||
def resize(self, width: int) -> None:
|
||||
"""Resize tile to new width."""
|
||||
self._width = width
|
||||
self._height = int(width * COVER_RATIO)
|
||||
self.set_size_request(self._width, self._height)
|
||||
self._picture.set_size_request(self._width, self._height)
|
||||
|
||||
# ─── Callbacks ────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_play_clicked(self, _btn: Gtk.Button) -> None:
|
||||
"""Emit play signal."""
|
||||
if hasattr(self, "_on_launch"):
|
||||
self._on_launch(self._app_id, self._name)
|
||||
|
||||
def _on_ce_clicked(self, _btn: Gtk.Button | None) -> None:
|
||||
"""Emit CE signal."""
|
||||
if hasattr(self, "_on_launch_ce"):
|
||||
self._on_launch_ce(self._app_id, self._name)
|
||||
|
||||
def _on_assign_table(self) -> None:
|
||||
"""Open file chooser to assign a .CT table."""
|
||||
if hasattr(self, "_on_pick_table"):
|
||||
self._on_pick_table(self._app_id)
|
||||
|
||||
def _on_remove_table(self) -> None:
|
||||
"""Remove the bound table."""
|
||||
tables.unbind(self._app_id)
|
||||
self._act_remove.set_enabled(False)
|
||||
self._update_ce_tooltip()
|
||||
|
||||
def _update_ce_tooltip(self) -> None:
|
||||
name = tables.get_table_name(self._app_id)
|
||||
if name:
|
||||
self._btn_ce.set_tooltip_text(f"CE: {name}")
|
||||
else:
|
||||
self._btn_ce.set_tooltip_text(_("Launch Cheat Engine"))
|
||||
|
||||
def mark_table_bound(self) -> None:
|
||||
"""Refresh table indicator after binding."""
|
||||
self._act_remove.set_enabled(tables.get_table(self._app_id) is not None)
|
||||
self._update_ce_tooltip()
|
||||
|
||||
def _on_switch_toggled(self, _switch: Gtk.Switch, state: bool) -> bool:
|
||||
"""Handle activation toggle."""
|
||||
self._is_active = state
|
||||
if hasattr(self, "_on_toggle"):
|
||||
self._on_toggle(self._app_id, state)
|
||||
return False
|
||||
|
||||
def _on_reload_clicked(self, _btn: Gtk.Button) -> None:
|
||||
"""Emit reload signal."""
|
||||
if hasattr(self, "_on_reload"):
|
||||
self._on_reload(self._app_id)
|
||||
|
||||
# ─── Connect handlers ─────────────────────────────────────────────────────
|
||||
|
||||
def connect_launch(self, callback) -> None:
|
||||
self._on_launch = callback
|
||||
|
||||
def connect_launch_ce(self, callback) -> None:
|
||||
self._on_launch_ce = callback
|
||||
|
||||
def connect_toggle(self, callback) -> None:
|
||||
self._on_toggle = callback
|
||||
|
||||
def connect_reload(self, callback) -> None:
|
||||
self._on_reload = callback
|
||||
|
||||
def connect_pick_table(self, callback) -> None:
|
||||
self._on_pick_table = callback
|
||||
GRID_SPACING = 12
|
||||
GRID_MARGIN = 16
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -634,9 +262,9 @@ class MainPage(Adw.Bin):
|
||||
height = tile._height
|
||||
|
||||
def worker():
|
||||
path = _download_cover(app_id)
|
||||
path = download_cover(app_id)
|
||||
if path:
|
||||
pixbuf = _load_pixbuf(path, width, height)
|
||||
pixbuf = load_pixbuf(path, width, height)
|
||||
GLib.idle_add(tile.set_cover, pixbuf)
|
||||
else:
|
||||
GLib.idle_add(tile.set_cover, None)
|
||||
@@ -650,7 +278,7 @@ class MainPage(Adw.Bin):
|
||||
return
|
||||
|
||||
# Delete cached cover
|
||||
cache = _cache_path(app_id)
|
||||
cache = cache_path(app_id)
|
||||
if cache.exists():
|
||||
cache.unlink()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user