mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +00:00
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>
622 lines
19 KiB
Python
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",
|
|
]
|