import os
import base64
from pathlib import Path
from typing import Optional, Dict, List, Tuple
import gradio as gr
class ThemeConfig:
"""Centralized theme configuration with validation."""
def __init__(self):
# Default color palette
self.primary_color = "#0F6CBD"
self.accent_color = "#C4314B"
self.success_color = "#2E7D32"
self.bg1 = "#F0F7FF"
self.bg2 = "#E8F0FA"
self.bg3 = "#DDE7F8"
self.font_family = (
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', "
"Roboto, 'Helvetica Neue', Arial, sans-serif"
)
# Metadata
self.project_name = "Heart Project"
self.year = "2025"
self.about = ""
self.description = ""
self.meta_items: List[Tuple[str, str]] = []
# Cache for CSS
self._css_cache: Optional[str] = None
def update_colors(self, **kwargs) -> None:
"""Update color scheme with validation."""
valid_keys = {'primary', 'accent', 'success', 'bg1', 'bg2', 'bg3'}
for key, value in kwargs.items():
if key not in valid_keys or value is None:
continue
if not self._is_valid_color(value):
raise ValueError(f"Invalid color format for {key}: {value}")
setattr(self, f"{key}_color" if not key.startswith('bg') else key, value)
self._invalidate_cache()
def update_font(self, font_family: str) -> None:
"""Update font family."""
if font_family and isinstance(font_family, str):
self.font_family = font_family
self._invalidate_cache()
def update_meta(self, project_name: Optional[str] = None,
year: Optional[str] = None,
about: Optional[str] = None,
description: Optional[str] = None,
meta_items: Optional[List[Tuple[str, str]]] = None) -> None:
"""Update metadata."""
if project_name is not None:
self.project_name = project_name
if year is not None:
self.year = year
if about is not None:
self.about = about
if description is not None:
self.description = description
if meta_items is not None:
self.meta_items = meta_items
@staticmethod
def _is_valid_color(color: str) -> bool:
"""Validate hex color format."""
return isinstance(color, str) and (
color.startswith('#') and len(color) in (4, 7, 9)
)
def _invalidate_cache(self) -> None:
"""Clear CSS cache when theme changes."""
self._css_cache = None
def get_css(self) -> str:
"""Get or generate CSS with caching."""
if self._css_cache is None:
self._css_cache = self._build_css()
return self._css_cache
def _build_css(self) -> str:
"""Build the complete CSS string."""
return f"""
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
.gradio-container {{
min-height: 100vh !important;
width: 100vw !important;
margin: 0 !important;
padding: 0px !important;
background: linear-gradient(135deg, {self.bg1} 0%, {self.bg2} 50%, {self.bg3} 100%);
background-size: 600% 600%;
animation: gradientBG 7s ease infinite;
}}
/* Global font setup */
body, .gradio-container, .gr-block, .gr-markdown, .gr-button, .gr-input,
.gr-dropdown, .gr-number, .gr-plot, .gr-dataframe, .gr-accordion, .gr-form,
.gr-textbox, .gr-html, table, th, td, label, h1, h2, h3, h4, h5, h6, p, span, div {{
font-family: {self.font_family} !important;
}}
@keyframes gradientBG {{
0% {{background-position: 0% 50%;}}
50% {{background-position: 100% 50%;}}
100% {{background-position: 0% 50%;}}
}}
/* Minimize spacing and padding */
.content-wrap {{
padding: 2px !important;
margin: 0 !important;
}}
/* Reduce component spacing */
.gr-row {{
gap: 5px !important;
margin: 2px 0 !important;
}}
.gr-column {{
gap: 4px !important;
padding: 4px !important;
}}
/* Accordion optimization */
.gr-accordion {{
margin: 4px 0 !important;
}}
.gr-accordion .gr-accordion-content {{
padding: 2px !important;
}}
/* Form elements spacing */
.gr-form {{
gap: 2px !important;
}}
/* Button styling */
.gr-button {{
margin: 2px 0 !important;
}}
/* DataFrame optimization */
.gr-dataframe {{
margin: 4px 0 !important;
}}
/* Remove horizontal scroll from data preview */
.gr-dataframe .wrap {{
overflow-x: auto !important;
max-width: 100% !important;
}}
/* Plot optimization */
.gr-plot {{
margin: 4px 0 !important;
}}
/* Reduce markdown margins */
.gr-markdown {{
margin: 2px 0 !important;
}}
/* Footer positioning */
.sticky-footer {{
position: fixed;
bottom: 0px;
left: 0;
width: 100%;
background: {self.bg1};
padding: 6px !important;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 1000;
}}
"""
# Global theme instance
_theme = ThemeConfig()
def configure(project_name: Optional[str] = None,
year: Optional[str] = None,
about: Optional[str] = None,
description: Optional[str] = None,
colors: Optional[Dict[str, str]] = None,
font_family: Optional[str] = None,
meta_items: Optional[List[Tuple[str, str]]] = None) -> None:
"""
One-call configuration for the entire theme.
Args:
project_name: Name of the project
year: Project year
about: About project
description: Project description
colors: Dict with keys: primary, accent, success, bg1, bg2, bg3
font_family: CSS font family string
meta_items: List of (label, value) tuples for metadata
"""
if colors:
_theme.update_colors(**colors)
if font_family:
_theme.update_font(font_family)
_theme.update_meta(project_name, year, about, description, meta_items)
def get_custom_css() -> str:
"""Get the current custom CSS."""
return _theme.get_css()
def _image_to_base64(image_path: str) -> str:
"""
Convert image to base64 string with better error handling.
Args:
image_path: Relative path to image file
Returns:
Base64 encoded string
Raises:
FileNotFoundError: If image file doesn't exist
"""
current_dir = Path(__file__).parent
full_path = current_dir / image_path
if not full_path.exists():
raise FileNotFoundError(f"Image not found: {full_path}")
with open(full_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def create_header(logo_path: str = "static/intelligent_retail.png") -> None:
"""
Create a header with logo and project name.
Args:
logo_path: Path to logo image
"""
with gr.Row():
with gr.Column(scale=2):
try:
logo_base64 = _image_to_base64(logo_path)
gr.HTML(
f""""""
)
except FileNotFoundError:
gr.HTML("
{_theme.about}