Refactor theming and styles: implement dynamic theme management, replace static styles with theme variables, and enhance game card UI

This commit is contained in:
mscrnt 2025-05-24 19:57:59 -07:00
parent a31fd760d9
commit da88ae71fe
15 changed files with 514 additions and 96 deletions

View File

@ -4,20 +4,25 @@ from agentm.views.login import LoginView
from agentm import DIAMBRA_CREDENTIALS_PATH
from agentm.utils.logger import log_with_caller
from agentm.logic.db import initialize_database
from agentm.theme.palette import get_theme # ← Add this import
class AgentMApp(App):
CSS_PATH = "assets/styles.tcss"
CSS_PATH = "theme/styles.tcss"
def on_mount(self) -> None:
"""Called when the app starts."""
log_with_caller("debug", "App mounted. Initializing database...")
initialize_database()
# Initialize global theme instance (dark mode for now)
log_with_caller("debug", "Initializing theme: dark")
get_theme("dark")
log_with_caller("debug", "Checking for credentials...")
if DIAMBRA_CREDENTIALS_PATH.exists():
token = DIAMBRA_CREDENTIALS_PATH.read_text().strip()
if token and len(token) > 10:
log_with_caller("info", "Found populated DIAMBRA credentials. Launching Home.")
self.push_screen(HomeView())

View File

@ -0,0 +1,3 @@
+-+-+-+-+ +-+-+ +-+-+-+-+-+ +-+-+
|D|E|A|D| |O|R| |A|L|I|V|E| |+|+|
+-+-+-+-+ +-+-+ +-+-+-+-+-+ +-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+ +-+-+ +-+-+-+-+-+-+-+-+
|K|i|n|g| |o|f| |F|i|g|h|t|e|r|s|
+-+-+-+-+ +-+-+ +-+-+-+-+-+-+-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+-+-+ +-+-+ +-+-+-+-+-+-+
|M|a|r|v|e|l| |v|s| |C|a|p|c|o|m|
+-+-+-+-+-+-+ +-+-+ +-+-+-+-+-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+
|S|a|m|u|r|a|i| |S|h|o|w|d|o|w|n|
+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+-+-+ +-+-+-+-+-+-+-+ +-+-+-+
|S|t|r|e|e|t| |F|i|g|h|t|e|r| |I|I|I|
+-+-+-+-+-+-+ +-+-+-+-+-+-+-+ +-+-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+ +-+-+-+-+-+-+-+
|S|o|u|l| |C|a|l|i|b|u|r|
+-+-+-+-+ +-+-+-+-+-+-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+-+-+ +-+-+-+
|T|e|k|k|e|n| |T|a|g|
+-+-+-+-+-+-+ +-+-+-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+-+-+ +-+-+-+-+-+-+ +-+
|M|o|r|t|a|l| |K|o|m|b|a|t| |3|
+-+-+-+-+-+-+ +-+-+-+-+-+-+ +-+

View File

@ -0,0 +1,3 @@
+-+-+-+-+-+-+ +-+-+-+-+-+-+ +-+
|M|o|r|t|a|l| |K|o|m|b|a|t| |3|
+-+-+-+-+-+-+ +-+-+-+-+-+-+ +-+

View File

@ -0,0 +1,29 @@
from agentm.theme.palette import get_theme
from pathlib import Path
import re
# Load dark theme instance
theme = get_theme("dark")
# Path setup
base_path = Path(__file__).parent
template_path = base_path / "styles.base.tcss"
output_path = base_path / "styles.tcss"
# Load template
template = template_path.read_text()
# Find all placeholders like {{FOREGROUND}}, {{BACKGROUND}}, etc.
tokens = set(re.findall(r"{{\s*([A-Z0-9_]+)\s*}}", template))
# Replace them with actual values from theme
for token in tokens:
value = getattr(theme, token, None)
if value is not None:
template = template.replace(f"{{{{{token}}}}}", value)
else:
print(f"⚠️ Warning: Theme token '{token}' not found in ThemeManager")
# Write final output
output_path.write_text(template)
print(f"✅ Synced themed CSS written to: {output_path}")

94
agentm/theme/palette.py Normal file
View File

@ -0,0 +1,94 @@
from typing import Literal, Optional
class ThemeManager:
def __init__(self, theme: Literal["dark", "light"] = "dark"):
self.use_theme(theme)
def use_theme(self, theme: str):
if theme == "dark":
self.ACCENT = "#ed7d3a"
self.ACCENT_HOVER = "rgb(236, 194, 169)"
self.BACKGROUND = "#0e0e0e"
self.FOREGROUND = "#f0f0f0"
self.BORDER = "#3a9bed"
self.SURFACE_MUTED = "#8b8b8b"
self.SUCCESS = "#4cd964"
self.ERROR = "red"
self.DISABLED = "#999999"
self.DISABLED_BG = "#444444"
self.DISABLED_BORDER = "#666666"
else:
self.ACCENT = "#004488"
self.ACCENT_HOVER = "#0077cc"
self.BACKGROUND = "#ffffff"
self.FOREGROUND = "#000000"
self.BORDER = "#003366"
self.SURFACE_MUTED = "#dddddd"
self.SUCCESS = "#007f00"
self.ERROR = "#cc0000"
self.DISABLED = "#cccccc"
self.DISABLED_BG = "#f0f0f0"
self.DISABLED_BORDER = "#aaaaaa"
# Shared base + primary + tonal
self.DARK = "#000000"
self.LIGHT = "#ffffff"
self.PRIMARY_0 = "#6e1106"
self.PRIMARY_10 = "#812f1f"
self.PRIMARY_20 = "#934937"
self.PRIMARY_30 = "#a46251"
self.PRIMARY_40 = "#b57b6b"
self.PRIMARY_50 = "#c59487"
self.SURFACE_0 = "#121212"
self.SURFACE_10 = "#282828"
self.SURFACE_20 = "#3f3f3f"
self.SURFACE_30 = "#575757"
self.SURFACE_40 = "#717171"
self.SURFACE_50 = "#8b8b8b"
self.TONAL_0 = "#1d1411"
self.TONAL_10 = "#322927"
self.TONAL_20 = "#48403e"
self.TONAL_30 = "#605856"
self.TONAL_40 = "#787270"
self.TONAL_50 = "#928c8b"
self.COMPONENT_CLASSES = {
"palette--foreground",
"palette--background",
"palette--accent",
"palette--accent-hover",
"palette--border",
"palette--surface-muted",
"palette--primary-0",
"palette--primary-10",
"palette--primary-20",
"palette--primary-30",
"palette--primary-40",
"palette--primary-50",
"palette--surface-0",
"palette--surface-10",
"palette--surface-20",
"palette--surface-30",
"palette--surface-40",
"palette--surface-50",
"palette--success",
"palette--error",
"palette--disabled",
"palette-bg--accent",
"palette-bg--background",
"palette-bg--surface",
}
# === Singleton-style global instance ===
_theme_instance: Optional[ThemeManager] = None
def get_theme(theme: Literal["dark", "light"] = "dark") -> ThemeManager:
global _theme_instance
if _theme_instance is None:
_theme_instance = ThemeManager(theme)
return _theme_instance

View File

@ -0,0 +1,225 @@
/* === Global App Styles === */
Screen {
background: {{BACKGROUND}};
color: {{FOREGROUND}};
}
/* === Resets === */
* {
padding: 0 1;
border: none;
}
/* === Headers === */
# Header, .header {
# dock: top;
# height: 3;
# content-align: center middle;
# background: {{SURFACE_10}};
# color: {{ACCENT}};
# text-style: bold;
# padding: 1 2;
# border: solid {{BORDER}};
# }
/* === Buttons === */
Button {
background: {{SURFACE_10}};
color: {{ACCENT}};
border: solid {{ACCENT}};
padding: 1 2;
margin: 1;
content-align: center middle;
text-style: bold;
}
Button:hover {
background: {{ACCENT}};
color: {{BACKGROUND}};
}
Button:focus {
background: {{SURFACE_20}};
border: solid {{ACCENT}};
}
/* === Inputs === */
Input {
background: {{SURFACE_10}};
color: {{FOREGROUND}};
border: solid {{BORDER}};
}
Input:focus {
background: {{SURFACE_20}};
border: solid {{ACCENT}};
}
/* === Alerts === */
.success {
color: {{SUCCESS}};
}
.warning {
color: {{ACCENT}};
}
.error {
color: {{ERROR}};
}
/* === Login Form === */
#login_form {
layout: vertical;
align-horizontal: center;
align-vertical: middle;
width: 60%;
height: auto;
padding: 2 4;
border: solid {{BORDER}};
}
#status_message {
color: {{ACCENT}};
padding: 1;
}
#pw_row {
layout: horizontal;
padding: 0;
}
#pw_row > * {
margin-right: 1;
}
#pw_row > *:last-child {
margin-right: 0;
}
/* === Loading Overlay === */
#loading_overlay {
dock: top;
background: {{SURFACE_10}};
color: {{FOREGROUND}};
padding: 1 2;
text-style: bold;
content-align: center middle;
}
/* === Game Layout === */
.centered_layout {
layout: vertical;
align-horizontal: center;
align-vertical: middle;
padding: 2;
width: 100%;
}
.rom_rows_container {
layout: vertical;
align-horizontal: center;
}
.game_card {
width: auto;
height: auto;
max-width: 60;
margin: 1;
padding: 1;
background: {{SURFACE_10}};
color: {{ACCENT}};
border: solid {{BORDER}};
content-align: center middle;
text-style: bold;
text-align: center;
}
.game_card:hover {
background: {{SURFACE_20}};
color: {{ACCENT_HOVER}};
}
Static {
text-align: center;
}
Horizontal {
content-align: center middle;
}
.game_card:focus {
background: {{SURFACE_20}};
border: solid {{ACCENT}};
}
.game_card:disabled {
background: {{SURFACE_10}};
color: {{BORDER}};
}
.game_card_clicked {
background: {{ACCENT}};
color: {{BACKGROUND}};
}
.offset_card {
width: 11;
height: auto;
visibility: hidden;
}
.rom_row {
layout: horizontal;
align-horizontal: center;
align-vertical: middle;
padding: 1 2;
width: 100%;
}
.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: {{ACCENT}};
color: {{BACKGROUND}};
}
Button:disabled {
background: {{DISABLED_BG}};
color: {{DISABLED}};
border: solid {{DISABLED_BORDER}};
text-style: dim;
}
.game_info {
padding: 1 2;
width: 100%;
margin-right: 1;
color: {{ACCENT}};
}
#game_info_box {
width: 80%;
height: auto;
}

View File

@ -14,21 +14,21 @@ Screen {
/* === Headers === */
Header, .header {
dock: top;
height: 3;
content-align: center middle;
background: #1f1f1f;
color: #ed7d3a;
text-style: bold;
padding: 1 2;
border: solid #3a9bed;
}
# Header, .header {
# dock: top;
# height: 3;
# content-align: center middle;
# background: #282828;
# color: #ed7d3a;
# text-style: bold;
# padding: 1 2;
# border: solid #3a9bed;
# }
/* === Buttons === */
Button {
background: #1f1f1f;
background: #282828;
color: #ed7d3a;
border: solid #ed7d3a;
padding: 1 2;
@ -43,20 +43,20 @@ Button:hover {
}
Button:focus {
background: #2a2a2a;
background: #3f3f3f;
border: solid #ed7d3a;
}
/* === Inputs === */
Input {
background: #1f1f1f;
background: #282828;
color: #f0f0f0;
border: solid #3a9bed;
}
Input:focus {
background: #2a2a2a;
background: #3f3f3f;
border: solid #ed7d3a;
}
@ -108,7 +108,7 @@ Input:focus {
#loading_overlay {
dock: top;
background: #1f1f1f;
background: #282828;
color: #f0f0f0;
padding: 1 2;
text-style: bold;
@ -130,42 +130,40 @@ Input:focus {
align-horizontal: center;
}
.rom_row {
layout: horizontal;
align-horizontal: center;
align-vertical: middle;
padding: 1 2;
width: 100%;
}
.game_card {
width: auto;
height: auto;
max-width: 60;
margin: 1;
padding: 1;
background: #1f1f1f;
background: #282828;
color: #ed7d3a;
border: solid #3a9bed;
content-align: center middle;
text-style: bold;
text-align: center;
}
.game_card:hover {
background: #2a2a2a;
color: #ed7d3a;
background: #3f3f3f;
color: rgb(236, 194, 169);
}
Static {
text-align: center;
}
Horizontal {
content-align: center middle;
}
.game_card:focus {
background: #2a2a2a;
background: #3f3f3f;
border: solid #ed7d3a;
}
.game_card:disabled {
background: #1f1f1f;
background: #282828;
color: #3a9bed;
}
@ -174,27 +172,20 @@ Input:focus {
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;
align-vertical: middle;
padding: 1 2;
width: 100%;
}
.rom_row {
layout: horizontal;
align-horizontal: center;
padding: 1 2; /* maybe increase vertical padding */
}
.info_confirm_row {
layout: horizontal;
width: 100%;
@ -203,7 +194,6 @@ Input:focus {
align-vertical: top;
}
#confirm_button {
width: 20%;
height: auto;
@ -215,12 +205,21 @@ Input:focus {
color: #0e0e0e;
}
Button:disabled {
background: #444444;
color: #999999;
border: solid #666666;
text-style: dim;
}
.game_info {
padding: 1 2;
width: 100%;
margin-right: 1;
color: #ed7d3a;
}
#game_info_box {
width: 80%;
height: auto;
}

View File

@ -6,7 +6,10 @@ from textual.reactive import reactive
from textual.widget import Widget
from rich.panel import Panel
from rich.console import Group
from PIL import Image, ImageEnhance, ImageFilter
from rich.table import Table
from rich.rule import Rule
from rich.markup import escape
from PIL import Image, ImageFilter
import os
from rich_pixels import Pixels
@ -14,6 +17,9 @@ from rich_pixels._renderer import HalfcellRenderer
from agentm.utils.logger import log_with_caller
from agentm.logic.roms import get_verified_roms, GAME_FILES
from agentm.theme.palette import get_theme
palette = get_theme()
class GameSelected(Message):
@ -26,7 +32,8 @@ class ProgressWidget(Widget):
message = reactive("Loading...")
def render(self) -> str:
return f"[bold cyan]{self.message}[/bold cyan]"
return f"[bold {palette.ACCENT}]{self.message}[/]"
class GameAccordion(Static):
@ -39,15 +46,25 @@ class GameAccordion(Static):
image_path = os.path.abspath(self.metadata.get("image_path", ""))
self.image_renderable = self.load_image_scaled(image_path)
self.ascii_title = self.load_ascii_art()
super().__init__(id=f"accordion_{self.safe_id}", classes="game_card")
def load_ascii_art(self) -> Static:
game_id = self.metadata.get("game_id", "").lower()
ascii_path = os.path.join("agentm", "assets", "game_titles", f"{game_id}.txt")
try:
with open(ascii_path, "r", encoding="utf-8") as f:
return Static(f"[bold {palette.ACCENT}]{f.read()}[/]", markup=True)
except FileNotFoundError:
return Static(f"[bold {palette.ACCENT}]{self.title.upper()}[/]", markup=True)
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
scale_factor = 0.098
target_width = int(img.width * scale_factor)
target_height = int(img.height * scale_factor)
resized = img.resize(
@ -66,17 +83,9 @@ class GameAccordion(Static):
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
)
def compose(self):
yield Static(self.image_renderable)
yield self.ascii_title
async def on_click(self):
await self.display_info()
@ -85,16 +94,22 @@ class GameAccordion(Static):
async def display_info(self):
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']}",
f"[b]Difficulty:[/b] {meta.get('difficulty_min')} - {meta.get('difficulty_max')}",
f"[b]Characters:[/b] {', '.join(meta.get('characters', []))}",
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))
table = Table.grid(expand=True)
table.add_column(ratio=1)
table.add_column()
table.add_row("[b]Title:[/b]", meta['title'])
table.add_row("[b]Game ID:[/b]", meta['game_id'])
table.add_row("[b]Difficulty:[/b]", f"{meta.get('difficulty_min')} - {meta.get('difficulty_max')}")
table.add_row("[b]Characters:[/b]", ", ".join(meta.get("characters", [])))
table.add_row("[b]Keywords:[/b]", ", ".join(meta.get("keywords", [])))
table.add_row("[b]SHA256:[/b]", meta["sha256"])
self.parent_view.shared_info_box.update(
Panel(Group(table, Rule(style="dim")), title="Game Info", border_style=palette.BORDER, expand=True)
)
self.parent_view.shared_confirm_button.label = f"✅ Confirm {meta['title']}"
self.parent_view.shared_confirm_button.disabled = False
self.parent_view.selected_game = meta
@ -109,7 +124,7 @@ class HomeView(Screen):
def compose(self):
self.logo = Static(
"[b bright_magenta]\n\n"
f"[bold {palette.ACCENT}]\n\n"
" █████╗ ██████╗ ███████╗ ███╗ ██╗ ████████╗ ███╗ ███╗\n"
"██╔══██╗ ██╔════╝ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ████╗ ████║\n"
"███████║ ██║ ███╗ █████╗ ██╔██╗██║ ██║ ██╔████╔██║\n"
@ -119,13 +134,30 @@ class HomeView(Screen):
classes="header",
expand=False,
)
self.welcome_text = Static(
"This is an unofficial DIAMBRA launcher to help you easily train, evaluate, and submit RL agents for fighting games.",
classes="body",
expand=False
)
self.progress_text = ProgressWidget()
self.loading_container = Vertical(
Static(f"[bold {palette.SUCCESS}]LOADING...[/]", expand=True),
self.progress_text,
id="loading_container"
)
# This will be the main container we later modify
self.dynamic_container = Vertical(self.loading_container, id="dynamic_content")
yield Vertical(
self.logo,
Static("[bold green]LOADING...[/bold green]", expand=True),
self.progress_text,
id="loading_container"
self.welcome_text,
self.dynamic_container,
id="home_screen_container",
classes="centered_layout"
)
async def on_mount(self):
@ -151,36 +183,42 @@ class HomeView(Screen):
async def display_verified_roms(self, verified_roms):
log_with_caller("info", f"ROM verification complete. Total: {len(verified_roms)}")
self.query_one("#loading_container").remove()
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_info_box = Static(
Panel(
"[dim]Select a Game From Above to Start[/dim]",
title="Game Info",
border_style=palette.BORDER,
expand=True
),
id="game_info_box",
classes="game_info",
expand=True
)
self.shared_confirm_button = Button(
"✅ Confirm",
id="confirm_button",
classes="confirm_button",
disabled=True
)
self.rom_scroll_row = HorizontalScroll(id="rom_scroll_row", classes="rom_row")
await self.mount(
Vertical(
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",
expand=False
),
self.rom_scroll_row,
Horizontal(
self.shared_info_box,
self.shared_confirm_button,
id="info_row",
classes="info_confirm_row",
),
id="home_screen_container",
classes="centered_layout"
new_content = Vertical(
self.rom_scroll_row,
Horizontal(
self.shared_info_box,
self.shared_confirm_button,
id="info_row",
classes="info_confirm_row"
)
)
# Replace loading content with new UI below logo and welcome
dynamic_container = self.query_one("#dynamic_content")
await dynamic_container.remove_children()
await dynamic_container.mount(new_content)
# Populate games
for rom in verified_roms:
await self.rom_scroll_row.mount(GameAccordion(
title=rom["title"],
@ -189,6 +227,7 @@ class HomeView(Screen):
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)