From a31fd760d9d47c569592073f992c1679120e0288 Mon Sep 17 00:00:00 2001 From: mscrnt Date: Wed, 21 May 2025 12:22:08 -0700 Subject: [PATCH] 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. --- agentm/assets/styles.tcss | 84 ++++++++++++++++++++++++-- agentm/logic/roms.py | 26 +++++++- agentm/views/home.py | 122 +++++++++++++++++++++++++------------- 3 files changed, 187 insertions(+), 45 deletions(-) diff --git a/agentm/assets/styles.tcss b/agentm/assets/styles.tcss index 0faa80e..c29fa34 100644 --- a/agentm/assets/styles.tcss +++ b/agentm/assets/styles.tcss @@ -22,6 +22,7 @@ Header, .header { color: #ed7d3a; text-style: bold; padding: 1 2; + border: solid #3a9bed; } /* === Buttons === */ @@ -33,6 +34,7 @@ Button { padding: 1 2; margin: 1; content-align: center middle; + text-style: bold; } Button:hover { @@ -136,11 +138,81 @@ Input:focus { width: 100%; } -.confirm_button { - align-horizontal: center; - align-vertical: top; - margin-top: 1; + +.game_card { 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 { @@ -148,3 +220,7 @@ Input:focus { width: 100%; color: #ed7d3a; } + +#game_info_box { + width: 80%; +} \ No newline at end of file diff --git a/agentm/logic/roms.py b/agentm/logic/roms.py index ca59129..9f28757 100644 --- a/agentm/logic/roms.py +++ b/agentm/logic/roms.py @@ -27,11 +27,13 @@ GAME_FILES = { "X-Men vs. Street Fighter": "xmvsf.zip", } + def get_image_path(rom_file: str) -> str: """Returns relative image path for a given ROM zip.""" base_name = os.path.splitext(rom_file)[0] return f"agentm/assets/game_images/{base_name}.jpg" + def get_verified_roms(): verified = [] @@ -51,6 +53,8 @@ def get_verified_roms(): cached = get_cached_rom(rom_file) 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})") verified.append(cached) 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 = sha256_match.group(1) if sha256_match else "" game_id = rom_file.replace(".zip", "") + image_path = get_image_path(rom_file) block = extract_game_block(full_list_output, game_id) difficulty_min = extract_line_int(block, "Difficulty levels", index=0) @@ -84,13 +89,32 @@ def get_verified_roms(): difficulty_max=difficulty_max, characters=characters, 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})") else: log_with_caller("error", f"✗ Invalid ROM: {title} ({rom_file})") - verified = get_all_verified_roms() + # If using DB records, append image paths before returning + if not verified: + 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)") return verified diff --git a/agentm/views/home.py b/agentm/views/home.py index ab3f2c7..f2bf2b0 100644 --- a/agentm/views/home.py +++ b/agentm/views/home.py @@ -1,11 +1,16 @@ from textual.screen import Screen 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.reactive import reactive from textual.widget import Widget -import asyncio -from math import ceil +from rich.panel import Panel +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.logic.roms import get_verified_roms, GAME_FILES @@ -24,27 +29,62 @@ class ProgressWidget(Widget): 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): - safe_id = rom_file.replace(".", "_").replace("-", "_") - super().__init__(id=f"accordion_{safe_id}") self.title = title self.rom_file = rom_file - self.safe_id = safe_id self.metadata = metadata self.parent_view = parent_view + self.safe_id = rom_file.replace(".", "_").replace("-", "_") - def compose(self): - yield Button(f"{self.title}", id=f"btn_{self.safe_id}", classes="game_button") + image_path = os.path.abspath(self.metadata.get("image_path", "")) + self.image_renderable = self.load_image_scaled(image_path) - async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == f"btn_{self.safe_id}": - await self.display_info() + super().__init__(id=f"accordion_{self.safe_id}", classes="game_card") + + 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() + self.parent_view.highlight_selected(self) async def display_info(self): - log_with_caller("debug", f"Showing shared info for {self.rom_file}") meta = self.metadata - + log_with_caller("debug", f"Showing shared info for {self.rom_file}") info_lines = [ f"[b]Title:[/b] {meta['title']}", 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]SHA256:[/b] {meta['sha256']}", ] - 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.selected_game = meta @@ -62,6 +101,12 @@ class GameAccordion(Vertical): class HomeView(Screen): 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): self.logo = Static( "[b bright_magenta]\n\n" @@ -69,10 +114,10 @@ class HomeView(Screen): "██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ████╗ ████║\n" "███████║ ██║ ███╗ █████╗ ██╔██╗██║ ██║ ██╔████╔██║\n" "██╔══██║ ██║ ██║ ██╔══╝ ██║╚████║ ██║ ██║╚██╔╝██║\n" - "██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║ ██║\n" - "╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝\n[/]", + "██║ ██║ ╚██████╔╝ ███████╗ ██║ ╚███║ ██║ ██║ ██║\n" + "╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚══╝ ╚═╝ ╚═╝ ╚═╝\n[/]", classes="header", - expand=True, + expand=False, ) self.progress_text = ProgressWidget() @@ -111,42 +156,39 @@ class HomeView(Screen): 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.rom_scroll_row = HorizontalScroll(id="rom_scroll_row", classes="rom_row") + await self.mount( Vertical( - Static("🎮 Welcome to Agent M", classes="header"), + self.logo, + Static("🎮 Welcome to Agent M", classes="header", expand=False), Static( "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.", - 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_confirm_button, + id="info_row", + classes="info_confirm_row", ), - self.shared_info_box, - self.shared_confirm_button, id="home_screen_container", 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): - rom_row = self.query_one(f"#rom_row_{i}", Horizontal) - for rom in row: - await rom_row.mount(GameAccordion( - title=rom["title"], - rom_file=rom["rom_file"], - metadata=rom, - parent_view=self - )) + for rom in verified_roms: + await self.rom_scroll_row.mount(GameAccordion( + title=rom["title"], + rom_file=rom["rom_file"], + metadata=rom, + parent_view=self + )) async def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "confirm_button" and self.selected_game: await self.app.push_screen("training", self.selected_game) -