Enhance game card styles and add image handling in the home view

- Updated styles for game cards, including hover and focus states.
- Integrated image loading for game cards, scaling images appropriately.
- Modified GameAccordion to display images and improved layout handling.
- Added image path management in ROM verification logic.
This commit is contained in:
mscrnt 2025-05-21 12:22:08 -07:00
parent 51e45d7600
commit a31fd760d9
3 changed files with 187 additions and 45 deletions

View File

@ -22,6 +22,7 @@ Header, .header {
color: #ed7d3a; color: #ed7d3a;
text-style: bold; text-style: bold;
padding: 1 2; padding: 1 2;
border: solid #3a9bed;
} }
/* === Buttons === */ /* === Buttons === */
@ -33,6 +34,7 @@ Button {
padding: 1 2; padding: 1 2;
margin: 1; margin: 1;
content-align: center middle; content-align: center middle;
text-style: bold;
} }
Button:hover { Button:hover {
@ -136,11 +138,81 @@ Input:focus {
width: 100%; width: 100%;
} }
.confirm_button {
align-horizontal: center; .game_card {
align-vertical: top;
margin-top: 1;
width: auto; width: auto;
height: auto;
max-width: 60;
margin: 1;
padding: 1;
background: #1f1f1f;
color: #ed7d3a;
border: solid #3a9bed;
content-align: center middle;
text-style: bold;
text-align: center;
}
.game_card:hover {
background: #2a2a2a;
color: #ed7d3a;
}
.game_card:focus {
background: #2a2a2a;
border: solid #ed7d3a;
}
.game_card:disabled {
background: #1f1f1f;
color: #3a9bed;
}
.game_card_clicked {
background: #ed7d3a;
color: #0e0e0e;
}
.offset_card {
width: 11;
height: auto;
visibility: hidden;
}
/* ✅ Keep this for consistent row layout */
.rom_row {
layout: horizontal;
align-horizontal: center;
padding: 1 1;
}
.rom_row {
layout: horizontal;
align-horizontal: center;
padding: 1 2; /* maybe increase vertical padding */
}
.info_confirm_row {
layout: horizontal;
width: 100%;
height: auto;
padding: 1 2;
align-vertical: top;
}
#confirm_button {
width: 20%;
height: auto;
content-align: center middle;
}
.confirm_button:hover {
background: #ed7d3a;
color: #0e0e0e;
} }
.game_info { .game_info {
@ -148,3 +220,7 @@ Input:focus {
width: 100%; width: 100%;
color: #ed7d3a; color: #ed7d3a;
} }
#game_info_box {
width: 80%;
}

View File

@ -27,11 +27,13 @@ GAME_FILES = {
"X-Men vs. Street Fighter": "xmvsf.zip", "X-Men vs. Street Fighter": "xmvsf.zip",
} }
def get_image_path(rom_file: str) -> str: def get_image_path(rom_file: str) -> str:
"""Returns relative image path for a given ROM zip.""" """Returns relative image path for a given ROM zip."""
base_name = os.path.splitext(rom_file)[0] base_name = os.path.splitext(rom_file)[0]
return f"agentm/assets/game_images/{base_name}.jpg" return f"agentm/assets/game_images/{base_name}.jpg"
def get_verified_roms(): def get_verified_roms():
verified = [] verified = []
@ -51,6 +53,8 @@ def get_verified_roms():
cached = get_cached_rom(rom_file) cached = get_cached_rom(rom_file)
if cached and cached["verified"]: if cached and cached["verified"]:
# Add image path to runtime version even if not stored in DB
cached["image_path"] = get_image_path(rom_file)
log_with_caller("info", f"✓ Cached ROM is valid: {title} ({rom_file})") log_with_caller("info", f"✓ Cached ROM is valid: {title} ({rom_file})")
verified.append(cached) verified.append(cached)
continue continue
@ -68,6 +72,7 @@ def get_verified_roms():
sha256_match = re.search(r"sha256\s*=\s*([a-f0-9]{64})", result.stdout, re.IGNORECASE) sha256_match = re.search(r"sha256\s*=\s*([a-f0-9]{64})", result.stdout, re.IGNORECASE)
sha256 = sha256_match.group(1) if sha256_match else "" sha256 = sha256_match.group(1) if sha256_match else ""
game_id = rom_file.replace(".zip", "") game_id = rom_file.replace(".zip", "")
image_path = get_image_path(rom_file)
block = extract_game_block(full_list_output, game_id) block = extract_game_block(full_list_output, game_id)
difficulty_min = extract_line_int(block, "Difficulty levels", index=0) difficulty_min = extract_line_int(block, "Difficulty levels", index=0)
@ -84,13 +89,32 @@ def get_verified_roms():
difficulty_max=difficulty_max, difficulty_max=difficulty_max,
characters=characters, characters=characters,
keywords=keywords keywords=keywords
# Note: if your DB schema supports image_path, include it here
) )
verified.append({
"title": title,
"rom_file": rom_file,
"game_id": game_id,
"sha256": sha256,
"difficulty_min": difficulty_min,
"difficulty_max": difficulty_max,
"characters": characters,
"keywords": keywords,
"verified": True,
"image_path": image_path,
})
log_with_caller("info", f"✓ Verified and cached: {title} ({rom_file})") log_with_caller("info", f"✓ Verified and cached: {title} ({rom_file})")
else: else:
log_with_caller("error", f"✗ Invalid ROM: {title} ({rom_file})") log_with_caller("error", f"✗ Invalid ROM: {title} ({rom_file})")
# If using DB records, append image paths before returning
if not verified:
verified = get_all_verified_roms() verified = get_all_verified_roms()
for v in verified:
v["image_path"] = get_image_path(v["rom_file"])
log_with_caller("info", f"ROM verification completed: {len(verified)} valid game(s)") log_with_caller("info", f"ROM verification completed: {len(verified)} valid game(s)")
return verified return verified

View File

@ -1,11 +1,16 @@
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Static, Button from textual.widgets import Static, Button
from textual.containers import Vertical, Horizontal from textual.containers import Vertical, Horizontal, HorizontalScroll
from textual.message import Message from textual.message import Message
from textual.reactive import reactive from textual.reactive import reactive
from textual.widget import Widget from textual.widget import Widget
import asyncio from rich.panel import Panel
from math import ceil from rich.console import Group
from PIL import Image, ImageEnhance, ImageFilter
import os
from rich_pixels import Pixels
from rich_pixels._renderer import HalfcellRenderer
from agentm.utils.logger import log_with_caller from agentm.utils.logger import log_with_caller
from agentm.logic.roms import get_verified_roms, GAME_FILES from agentm.logic.roms import get_verified_roms, GAME_FILES
@ -24,27 +29,62 @@ class ProgressWidget(Widget):
return f"[bold cyan]{self.message}[/bold cyan]" return f"[bold cyan]{self.message}[/bold cyan]"
class GameAccordion(Vertical): class GameAccordion(Static):
def __init__(self, title: str, rom_file: str, metadata: dict, parent_view): def __init__(self, title: str, rom_file: str, metadata: dict, parent_view):
safe_id = rom_file.replace(".", "_").replace("-", "_")
super().__init__(id=f"accordion_{safe_id}")
self.title = title self.title = title
self.rom_file = rom_file self.rom_file = rom_file
self.safe_id = safe_id
self.metadata = metadata self.metadata = metadata
self.parent_view = parent_view self.parent_view = parent_view
self.safe_id = rom_file.replace(".", "_").replace("-", "_")
def compose(self): image_path = os.path.abspath(self.metadata.get("image_path", ""))
yield Button(f"{self.title}", id=f"btn_{self.safe_id}", classes="game_button") self.image_renderable = self.load_image_scaled(image_path)
async def on_button_pressed(self, event: Button.Pressed) -> None: super().__init__(id=f"accordion_{self.safe_id}", classes="game_card")
if event.button.id == f"btn_{self.safe_id}":
def load_image_scaled(self, path: str):
try:
with Image.open(path) as img:
if img.mode != "RGBA":
img = img.convert("RGBA")
scale_factor = 0.10
target_width = int(img.width * scale_factor)
target_height = int(img.height * scale_factor)
resized = img.resize(
(target_width, target_height),
resample=Image.Resampling.LANCZOS
)
resized = resized.filter(ImageFilter.UnsharpMask(radius=1, percent=150, threshold=3))
self._render_width = target_width
self._render_height = target_height // 2
return Pixels.from_image(
resized,
renderer=HalfcellRenderer(default_color="black"),
)
except Exception as e:
self._render_width = 24
self._render_height = 16
return f"[red]Failed to load image[/red]\n[dim]{e}]"
def render(self):
return Panel(
Group(
self.image_renderable,
"", # spacer
f"[center][bold orange1][u]{self.title.upper()}[/u][/bold orange1][/center]\n",
),
border_style="bright_magenta",
padding=(1, 2),
expand=False
)
async def on_click(self):
await self.display_info() await self.display_info()
self.parent_view.highlight_selected(self)
async def display_info(self): async def display_info(self):
log_with_caller("debug", f"Showing shared info for {self.rom_file}")
meta = self.metadata meta = self.metadata
log_with_caller("debug", f"Showing shared info for {self.rom_file}")
info_lines = [ info_lines = [
f"[b]Title:[/b] {meta['title']}", f"[b]Title:[/b] {meta['title']}",
f"[b]Game ID:[/b] {meta['game_id']}", f"[b]Game ID:[/b] {meta['game_id']}",
@ -53,7 +93,6 @@ class GameAccordion(Vertical):
f"[b]Keywords:[/b] {', '.join(meta.get('keywords', []))}", f"[b]Keywords:[/b] {', '.join(meta.get('keywords', []))}",
f"[b]SHA256:[/b] {meta['sha256']}", f"[b]SHA256:[/b] {meta['sha256']}",
] ]
self.parent_view.shared_info_box.update("\n".join(info_lines)) self.parent_view.shared_info_box.update("\n".join(info_lines))
self.parent_view.shared_confirm_button.label = f"✅ Confirm {meta['title']}" self.parent_view.shared_confirm_button.label = f"✅ Confirm {meta['title']}"
self.parent_view.selected_game = meta self.parent_view.selected_game = meta
@ -62,6 +101,12 @@ class GameAccordion(Vertical):
class HomeView(Screen): class HomeView(Screen):
BINDINGS = [("escape", "app.quit", "Quit")] BINDINGS = [("escape", "app.quit", "Quit")]
def highlight_selected(self, selected_widget: GameAccordion):
for card in self.rom_scroll_row.children:
if isinstance(card, GameAccordion):
card.remove_class("game_card_clicked")
selected_widget.add_class("game_card_clicked")
def compose(self): def compose(self):
self.logo = Static( self.logo = Static(
"[b bright_magenta]\n\n" "[b bright_magenta]\n\n"
@ -69,10 +114,10 @@ class HomeView(Screen):
"██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ████╗ ████║\n" "██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ████╗ ████║\n"
"███████║ ██║ ███╗ █████╗ ██╔██╗██║ ██║ ██╔████╔██║\n" "███████║ ██║ ███╗ █████╗ ██╔██╗██║ ██║ ██╔████╔██║\n"
"██╔══██║ ██║ ██║ ██╔══╝ ██║╚████║ ██║ ██║╚██╔╝██║\n" "██╔══██║ ██║ ██║ ██╔══╝ ██║╚████║ ██║ ██║╚██╔╝██║\n"
"██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║ ██║\n" "██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║\n"
"╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝\n[/]", "╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝\n[/]",
classes="header", classes="header",
expand=True, expand=False,
) )
self.progress_text = ProgressWidget() self.progress_text = ProgressWidget()
@ -111,35 +156,33 @@ class HomeView(Screen):
self.shared_info_box = Static("", id="game_info_box", classes="game_info") self.shared_info_box = Static("", id="game_info_box", classes="game_info")
self.shared_confirm_button = Button("✅ Confirm", id="confirm_button", classes="confirm_button") self.shared_confirm_button = Button("✅ Confirm", id="confirm_button", classes="confirm_button")
self.rom_scroll_row = HorizontalScroll(id="rom_scroll_row", classes="rom_row")
await self.mount( await self.mount(
Vertical( Vertical(
Static("🎮 Welcome to Agent M", classes="header"), self.logo,
Static("🎮 Welcome to Agent M", classes="header", expand=False),
Static( Static(
"This is an unofficial DIAMBRA launcher to help you easily train, evaluate, and submit RL agents for fighting games.\n\n" "This is an unofficial DIAMBRA launcher to help you easily train, evaluate, and submit RL agents for fighting games.\n\n"
"Verified ROMs are shown below. Click one to view game info and confirm selection.", "Verified ROMs are shown below. Click one to view game info and confirm selection.",
classes="body" classes="body",
), expand=False
Static(f"✅ Found {len(verified_roms)} valid ROM(s).", id="status_message"),
Vertical(
Horizontal(id="rom_row_1", classes="rom_row"),
Horizontal(id="rom_row_2", classes="rom_row"),
id="rom_rows",
classes="rom_rows_container"
), ),
self.rom_scroll_row,
Horizontal(
self.shared_info_box, self.shared_info_box,
self.shared_confirm_button, self.shared_confirm_button,
id="info_row",
classes="info_confirm_row",
),
id="home_screen_container", id="home_screen_container",
classes="centered_layout" classes="centered_layout"
) )
) )
per_row = ceil(len(verified_roms) / 2)
rows = [verified_roms[:per_row], verified_roms[per_row:]]
for i, row in enumerate(rows, start=1): for rom in verified_roms:
rom_row = self.query_one(f"#rom_row_{i}", Horizontal) await self.rom_scroll_row.mount(GameAccordion(
for rom in row:
await rom_row.mount(GameAccordion(
title=rom["title"], title=rom["title"],
rom_file=rom["rom_file"], rom_file=rom["rom_file"],
metadata=rom, metadata=rom,
@ -149,4 +192,3 @@ class HomeView(Screen):
async def on_button_pressed(self, event: Button.Pressed) -> None: async def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "confirm_button" and self.selected_game: if event.button.id == "confirm_button" and self.selected_game:
await self.app.push_screen("training", self.selected_game) await self.app.push_screen("training", self.selected_game)