Files
metabuilder/python/fakemui/theming.py
johndoe6345789 c406b8df96 refactor: Reorganize FakeMUI by implementation type
Move FakeMUI content to appropriate root-level folders by implementation:

**React Components → components/fakemui/**
- 537 components (inputs, surfaces, layout, data-display, feedback,
  navigation, utils, atoms, lab, x, email, workflows)
- 416 SVG icons
- Full barrel exports in components/fakemui/index.ts

**QML Components → qml/**
- 104 Material Design 3 components (11 categories)
- 7 hybrid application views
- 8 desktop widgets
- qmldir module registration

**Python Bindings → python/fakemui/**
- 15 PyQt6 modules (120+ components)
- Full Python package structure with pyproject.toml

**SCSS/Styles → fakemui/** (renamed purpose)
- scss/ - Material Design 3 stylesheets
- styles/ - Component SCSS modules
- src/utils/ - Accessibility utilities
- index.ts now re-exports from components/fakemui/

This separation allows:
- React: import { Button } from '@metabuilder/components/fakemui'
- QML: import QmlComponents 1.0
- Python: from fakemui import Button, Card
- Backward compat: import { Button } from '@metabuilder/fakemui'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:59:24 +00:00

622 lines
19 KiB
Python

"""
FakeMUI Theming Module for PyQt6
Provides theme management, styled components, and sx prop processing.
"""
from typing import Any, Callable, Dict, Optional, Type, Union
from dataclasses import dataclass, field
from PyQt6.QtWidgets import QWidget, QApplication
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtGui import QColor, QFont
# =============================================================================
# Default Theme Configuration
# =============================================================================
@dataclass
class Palette:
"""Color palette configuration."""
# Primary colors
primary_main: str = "#1976d2"
primary_light: str = "#42a5f5"
primary_dark: str = "#1565c0"
primary_contrast_text: str = "#ffffff"
# Secondary colors
secondary_main: str = "#9c27b0"
secondary_light: str = "#ba68c8"
secondary_dark: str = "#7b1fa2"
secondary_contrast_text: str = "#ffffff"
# Error colors
error_main: str = "#d32f2f"
error_light: str = "#ef5350"
error_dark: str = "#c62828"
error_contrast_text: str = "#ffffff"
# Warning colors
warning_main: str = "#ed6c02"
warning_light: str = "#ff9800"
warning_dark: str = "#e65100"
warning_contrast_text: str = "#ffffff"
# Info colors
info_main: str = "#0288d1"
info_light: str = "#03a9f4"
info_dark: str = "#01579b"
info_contrast_text: str = "#ffffff"
# Success colors
success_main: str = "#2e7d32"
success_light: str = "#4caf50"
success_dark: str = "#1b5e20"
success_contrast_text: str = "#ffffff"
# Grey scale
grey_50: str = "#fafafa"
grey_100: str = "#f5f5f5"
grey_200: str = "#eeeeee"
grey_300: str = "#e0e0e0"
grey_400: str = "#bdbdbd"
grey_500: str = "#9e9e9e"
grey_600: str = "#757575"
grey_700: str = "#616161"
grey_800: str = "#424242"
grey_900: str = "#212121"
# Text colors
text_primary: str = "rgba(0, 0, 0, 0.87)"
text_secondary: str = "rgba(0, 0, 0, 0.6)"
text_disabled: str = "rgba(0, 0, 0, 0.38)"
# Background colors
background_default: str = "#ffffff"
background_paper: str = "#ffffff"
# Divider
divider: str = "rgba(0, 0, 0, 0.12)"
# Action colors
action_active: str = "rgba(0, 0, 0, 0.54)"
action_hover: str = "rgba(0, 0, 0, 0.04)"
action_selected: str = "rgba(0, 0, 0, 0.08)"
action_disabled: str = "rgba(0, 0, 0, 0.26)"
action_disabled_background: str = "rgba(0, 0, 0, 0.12)"
mode: str = "light"
@dataclass
class Typography:
"""Typography configuration."""
font_family: str = '"Roboto", "Helvetica", "Arial", sans-serif'
font_size: int = 14
font_weight_light: int = 300
font_weight_regular: int = 400
font_weight_medium: int = 500
font_weight_bold: int = 700
# Variant sizes
h1_size: str = "96px"
h2_size: str = "60px"
h3_size: str = "48px"
h4_size: str = "34px"
h5_size: str = "24px"
h6_size: str = "20px"
subtitle1_size: str = "16px"
subtitle2_size: str = "14px"
body1_size: str = "16px"
body2_size: str = "14px"
button_size: str = "14px"
caption_size: str = "12px"
overline_size: str = "12px"
@dataclass
class Shape:
"""Shape configuration."""
border_radius: int = 4
@dataclass
class Shadows:
"""Shadow configuration."""
def __post_init__(self):
self.values = [
"none",
"0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12)",
"0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12)",
"0px 3px 3px -2px rgba(0,0,0,0.2),0px 3px 4px 0px rgba(0,0,0,0.14),0px 1px 8px 0px rgba(0,0,0,0.12)",
"0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)",
"0px 3px 5px -1px rgba(0,0,0,0.2),0px 5px 8px 0px rgba(0,0,0,0.14),0px 1px 14px 0px rgba(0,0,0,0.12)",
"0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12)",
"0px 4px 5px -2px rgba(0,0,0,0.2),0px 7px 10px 1px rgba(0,0,0,0.14),0px 2px 16px 1px rgba(0,0,0,0.12)",
"0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12)",
]
def __getitem__(self, index: int) -> str:
if 0 <= index < len(self.values):
return self.values[index]
return self.values[-1]
@dataclass
class Transitions:
"""Transition configuration."""
duration_shortest: int = 150
duration_shorter: int = 200
duration_short: int = 250
duration_standard: int = 300
duration_complex: int = 375
duration_entering_screen: int = 225
duration_leaving_screen: int = 195
easing_ease_in_out: str = "cubic-bezier(0.4, 0, 0.2, 1)"
easing_ease_out: str = "cubic-bezier(0.0, 0, 0.2, 1)"
easing_ease_in: str = "cubic-bezier(0.4, 0, 1, 1)"
easing_sharp: str = "cubic-bezier(0.4, 0, 0.6, 1)"
@dataclass
class ZIndex:
"""Z-index configuration."""
mobile_stepper: int = 1000
fab: int = 1050
speed_dial: int = 1050
app_bar: int = 1100
drawer: int = 1200
modal: int = 1300
snackbar: int = 1400
tooltip: int = 1500
@dataclass
class Breakpoints:
"""Breakpoint configuration."""
xs: int = 0
sm: int = 600
md: int = 900
lg: int = 1200
xl: int = 1536
def up(self, key: str) -> str:
"""Get media query for screens larger than breakpoint."""
value = getattr(self, key, 0)
return f"@media (min-width: {value}px)"
def down(self, key: str) -> str:
"""Get media query for screens smaller than breakpoint."""
value = getattr(self, key, 0)
return f"@media (max-width: {value - 0.05}px)"
def between(self, start: str, end: str) -> str:
"""Get media query for screens between two breakpoints."""
start_val = getattr(self, start, 0)
end_val = getattr(self, end, 0)
return f"@media (min-width: {start_val}px) and (max-width: {end_val - 0.05}px)"
@dataclass
class Theme:
"""Complete theme configuration."""
palette: Palette = field(default_factory=Palette)
typography: Typography = field(default_factory=Typography)
shape: Shape = field(default_factory=Shape)
shadows: Shadows = field(default_factory=Shadows)
transitions: Transitions = field(default_factory=Transitions)
zIndex: ZIndex = field(default_factory=ZIndex)
breakpoints: Breakpoints = field(default_factory=Breakpoints)
def spacing(self, *args: float) -> str:
"""
Calculate spacing based on 8px unit.
spacing(1) -> "8px"
spacing(1, 2) -> "8px 16px"
spacing(1, 2, 3, 4) -> "8px 16px 24px 32px"
"""
if not args:
return "0px"
return " ".join(f"{int(v * 8)}px" for v in args)
# Default theme instance
default_theme = Theme()
def create_theme(options: Optional[Dict[str, Any]] = None) -> Theme:
"""
Create a new theme with custom options.
Args:
options: Dictionary of theme options to override defaults.
Returns:
A new Theme instance.
"""
theme = Theme()
if options:
# Handle palette options
if "palette" in options:
palette_opts = options["palette"]
if "mode" in palette_opts and palette_opts["mode"] == "dark":
# Apply dark mode defaults
theme.palette.mode = "dark"
theme.palette.background_default = "#121212"
theme.palette.background_paper = "#1e1e1e"
theme.palette.text_primary = "rgba(255, 255, 255, 0.87)"
theme.palette.text_secondary = "rgba(255, 255, 255, 0.6)"
theme.palette.text_disabled = "rgba(255, 255, 255, 0.38)"
theme.palette.divider = "rgba(255, 255, 255, 0.12)"
# Override specific palette colors
for key, value in palette_opts.items():
if isinstance(value, dict):
for sub_key, sub_value in value.items():
attr_name = f"{key}_{sub_key}"
if hasattr(theme.palette, attr_name):
setattr(theme.palette, attr_name, sub_value)
elif hasattr(theme.palette, key):
setattr(theme.palette, key, value)
# Handle typography options
if "typography" in options:
for key, value in options["typography"].items():
if hasattr(theme.typography, key):
setattr(theme.typography, key, value)
# Handle shape options
if "shape" in options:
for key, value in options["shape"].items():
if hasattr(theme.shape, key):
setattr(theme.shape, key, value)
return theme
# =============================================================================
# Theme Provider
# =============================================================================
class ThemeProvider(QObject):
"""
Provides theme context for FakeMUI components.
Usage:
theme = create_theme({"palette": {"mode": "dark"}})
provider = ThemeProvider(theme)
provider.apply_to(my_widget)
"""
theme_changed = pyqtSignal(object)
_instance: Optional['ThemeProvider'] = None
_current_theme: Theme = default_theme
def __init__(self, theme: Optional[Theme] = None):
super().__init__()
self._theme = theme or default_theme
ThemeProvider._current_theme = self._theme
ThemeProvider._instance = self
@property
def theme(self) -> Theme:
return self._theme
@theme.setter
def theme(self, value: Theme):
self._theme = value
ThemeProvider._current_theme = value
self.theme_changed.emit(value)
@classmethod
def get_theme(cls) -> Theme:
"""Get the current theme."""
return cls._current_theme
@classmethod
def get_instance(cls) -> Optional['ThemeProvider']:
"""Get the current theme provider instance."""
return cls._instance
def apply_to(self, widget: QWidget):
"""Apply theme stylesheet to a widget."""
stylesheet = self.generate_stylesheet()
widget.setStyleSheet(stylesheet)
def generate_stylesheet(self) -> str:
"""Generate a complete stylesheet from the current theme."""
p = self._theme.palette
t = self._theme.typography
s = self._theme.shape
return f"""
/* Base styles */
QWidget {{
font-family: {t.font_family};
font-size: {t.font_size}px;
color: {p.text_primary};
background-color: {p.background_default};
}}
/* Primary button */
.fakemui-button-primary {{
background-color: {p.primary_main};
color: {p.primary_contrast_text};
border-radius: {s.border_radius}px;
}}
.fakemui-button-primary:hover {{
background-color: {p.primary_dark};
}}
/* Secondary button */
.fakemui-button-secondary {{
background-color: {p.secondary_main};
color: {p.secondary_contrast_text};
border-radius: {s.border_radius}px;
}}
/* Error button */
.fakemui-button-error {{
background-color: {p.error_main};
color: {p.error_contrast_text};
border-radius: {s.border_radius}px;
}}
/* Paper/Card */
.fakemui-paper, .fakemui-card {{
background-color: {p.background_paper};
border-radius: {s.border_radius}px;
}}
/* Input fields */
QLineEdit, QTextEdit, QPlainTextEdit {{
background-color: {p.background_paper};
color: {p.text_primary};
border: 1px solid {p.divider};
border-radius: {s.border_radius}px;
}}
/* Labels */
QLabel {{
color: {p.text_primary};
}}
/* Disabled state */
*:disabled {{
color: {p.text_disabled};
}}
"""
def use_theme() -> Theme:
"""
Hook-like function to get the current theme.
Returns:
The current theme instance.
"""
return ThemeProvider.get_theme()
# =============================================================================
# Styled Components
# =============================================================================
# Shorthand property mappings for sx prop
SX_SHORTHANDS = {
"m": "margin",
"mt": "margin-top",
"mr": "margin-right",
"mb": "margin-bottom",
"ml": "margin-left",
"mx": ("margin-left", "margin-right"),
"my": ("margin-top", "margin-bottom"),
"p": "padding",
"pt": "padding-top",
"pr": "padding-right",
"pb": "padding-bottom",
"pl": "padding-left",
"px": ("padding-left", "padding-right"),
"py": ("padding-top", "padding-bottom"),
"bgcolor": "background-color",
"color": "color",
"width": "width",
"height": "height",
"minWidth": "min-width",
"maxWidth": "max-width",
"minHeight": "min-height",
"maxHeight": "max-height",
"display": "display",
"overflow": "overflow",
"textAlign": "text-align",
"fontWeight": "font-weight",
"fontSize": "font-size",
"fontFamily": "font-family",
"lineHeight": "line-height",
"letterSpacing": "letter-spacing",
"textTransform": "text-transform",
"border": "border",
"borderTop": "border-top",
"borderRight": "border-right",
"borderBottom": "border-bottom",
"borderLeft": "border-left",
"borderColor": "border-color",
"borderRadius": "border-radius",
"boxShadow": "box-shadow",
"zIndex": "z-index",
"position": "position",
"top": "top",
"right": "right",
"bottom": "bottom",
"left": "left",
"flex": "flex",
"flexGrow": "flex-grow",
"flexShrink": "flex-shrink",
"flexBasis": "flex-basis",
"justifyContent": "justify-content",
"alignItems": "align-items",
"alignContent": "align-content",
"alignSelf": "align-self",
"gap": "gap",
"gridTemplateColumns": "grid-template-columns",
"gridTemplateRows": "grid-template-rows",
"gridColumn": "grid-column",
"gridRow": "grid-row",
}
def process_sx_value(value: Any, theme: Theme) -> str:
"""
Process an sx prop value, converting theme references.
Args:
value: The value to process (can be number for spacing, string, etc.)
theme: The current theme.
Returns:
A CSS-compatible value string.
"""
if isinstance(value, (int, float)):
# Treat numbers as spacing units
return theme.spacing(value)
elif isinstance(value, str):
# Check for theme color references
if value.startswith("primary"):
return getattr(theme.palette, f"primary_{value.split('.')[1] if '.' in value else 'main'}", value)
elif value.startswith("secondary"):
return getattr(theme.palette, f"secondary_{value.split('.')[1] if '.' in value else 'main'}", value)
elif value.startswith("error"):
return getattr(theme.palette, f"error_{value.split('.')[1] if '.' in value else 'main'}", value)
elif value.startswith("grey"):
return getattr(theme.palette, f"grey_{value.split('.')[1] if '.' in value else '500'}", value)
return value
return str(value)
def process_sx_prop(sx: Dict[str, Any], theme: Optional[Theme] = None) -> str:
"""
Process an sx prop dictionary into CSS styles.
Args:
sx: Dictionary of style properties (can use MUI shorthands).
theme: Optional theme for value processing.
Returns:
A CSS style string.
"""
if theme is None:
theme = use_theme()
styles = []
for key, value in sx.items():
css_prop = SX_SHORTHANDS.get(key, key)
processed_value = process_sx_value(value, theme)
if isinstance(css_prop, tuple):
# Handle shorthand properties that expand to multiple
for prop in css_prop:
styles.append(f"{prop}: {processed_value}")
else:
styles.append(f"{css_prop}: {processed_value}")
return "; ".join(styles)
def styled(base_class: Type[QWidget]) -> Callable:
"""
Create a styled component factory.
Usage:
StyledButton = styled(QPushButton)(
lambda theme: f'''
background-color: {theme.palette.primary_main};
color: {theme.palette.primary_contrast_text};
'''
)
button = StyledButton()
Args:
base_class: The base QWidget class to style.
Returns:
A factory function that creates the styled component.
"""
def style_factory(style_fn: Callable[[Theme], str]) -> Type[QWidget]:
class StyledWidget(base_class):
def __init__(self, *args, sx: Optional[Dict[str, Any]] = None, **kwargs):
super().__init__(*args, **kwargs)
theme = use_theme()
# Apply base styles from style function
base_styles = style_fn(theme)
# Apply sx prop styles
sx_styles = ""
if sx:
sx_styles = process_sx_prop(sx, theme)
# Combine and set stylesheet
combined = base_styles
if sx_styles:
combined = f"{base_styles}; {sx_styles}"
self.setStyleSheet(combined)
# Store for potential updates
self._style_fn = style_fn
self._sx = sx
def update_theme(self, theme: Theme):
"""Update component when theme changes."""
base_styles = self._style_fn(theme)
sx_styles = ""
if self._sx:
sx_styles = process_sx_prop(self._sx, theme)
combined = base_styles
if sx_styles:
combined = f"{base_styles}; {sx_styles}"
self.setStyleSheet(combined)
return StyledWidget
return style_factory
# =============================================================================
# Exports
# =============================================================================
__all__ = [
# Theme
"Theme",
"Palette",
"Typography",
"Shape",
"Shadows",
"Transitions",
"ZIndex",
"Breakpoints",
"default_theme",
"create_theme",
# Provider
"ThemeProvider",
"use_theme",
# Styled
"styled",
"process_sx_prop",
"SX_SHORTHANDS",
]