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>
605 lines
16 KiB
Python
605 lines
16 KiB
Python
"""Utility components for FakeMUI."""
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QFrame, QDialog, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QPushButton, QGraphicsOpacityEffect, QApplication
|
|
)
|
|
from PyQt6.QtCore import (
|
|
Qt, pyqtSignal, QPropertyAnimation, QEasingCurve,
|
|
QPoint, QTimer, QEvent, QObject
|
|
)
|
|
from PyQt6.QtGui import QColor
|
|
|
|
from .base import FakeMUIWidget, FakeMUIContainer, StyleMixin, THEME_COLORS, DARK_THEME_COLORS
|
|
|
|
|
|
class Modal(QDialog, StyleMixin):
|
|
"""Modal dialog component."""
|
|
|
|
_base_class = 'modal'
|
|
|
|
onClose = pyqtSignal()
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
open: bool = False,
|
|
closeOnBackdropClick: bool = True,
|
|
closeOnEscape: bool = True,
|
|
):
|
|
super().__init__(parent)
|
|
|
|
self._close_on_backdrop = closeOnBackdropClick
|
|
self._close_on_escape = closeOnEscape
|
|
|
|
self.setModal(True)
|
|
self.setWindowFlags(
|
|
Qt.WindowType.Dialog |
|
|
Qt.WindowType.FramelessWindowHint
|
|
)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
|
|
# Main layout with backdrop
|
|
self._main_layout = QVBoxLayout(self)
|
|
self._main_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Backdrop
|
|
self._backdrop = QFrame()
|
|
self._backdrop.setStyleSheet('background-color: rgba(0, 0, 0, 0.5);')
|
|
self._backdrop.mousePressEvent = self._backdrop_clicked
|
|
|
|
# Content container
|
|
self._content = QFrame()
|
|
self._content.setObjectName('modal-content')
|
|
self._content_layout = QVBoxLayout(self._content)
|
|
|
|
if open:
|
|
self.show()
|
|
|
|
def _backdrop_clicked(self, event):
|
|
"""Handle backdrop click."""
|
|
if self._close_on_backdrop:
|
|
self.close_modal()
|
|
|
|
def keyPressEvent(self, event):
|
|
"""Handle key press."""
|
|
if event.key() == Qt.Key.Key_Escape and self._close_on_escape:
|
|
self.close_modal()
|
|
else:
|
|
super().keyPressEvent(event)
|
|
|
|
def close_modal(self):
|
|
"""Close the modal."""
|
|
self.onClose.emit()
|
|
self.hide()
|
|
|
|
def add_widget(self, widget: QWidget):
|
|
"""Add widget to modal content."""
|
|
self._content_layout.addWidget(widget)
|
|
|
|
|
|
class Dialog(Modal):
|
|
"""Dialog component (alias for Modal with additional structure)."""
|
|
|
|
_base_class = 'dialog'
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
open: bool = False,
|
|
fullWidth: bool = False,
|
|
maxWidth: str = 'sm', # xs, sm, md, lg, xl
|
|
fullScreen: bool = False,
|
|
):
|
|
super().__init__(parent, open)
|
|
|
|
modifiers = [maxWidth]
|
|
if fullWidth:
|
|
modifiers.append('full-width')
|
|
if fullScreen:
|
|
modifiers.append('full-screen')
|
|
self.set_style_class(*modifiers)
|
|
|
|
# Max widths
|
|
max_widths = {
|
|
'xs': 444,
|
|
'sm': 600,
|
|
'md': 900,
|
|
'lg': 1200,
|
|
'xl': 1536,
|
|
}
|
|
|
|
if fullScreen:
|
|
self.showFullScreen()
|
|
elif maxWidth in max_widths:
|
|
self._content.setMaximumWidth(max_widths[maxWidth])
|
|
|
|
|
|
class DialogOverlay(QFrame, StyleMixin):
|
|
"""Dialog backdrop/overlay."""
|
|
|
|
_base_class = 'dialog-overlay'
|
|
|
|
clicked = pyqtSignal()
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
|
|
self.setStyleSheet('background-color: rgba(0, 0, 0, 0.5);')
|
|
|
|
def mousePressEvent(self, event):
|
|
"""Handle click."""
|
|
self.clicked.emit()
|
|
|
|
|
|
class DialogHeader(FakeMUIContainer):
|
|
"""Dialog header section."""
|
|
|
|
_base_class = 'dialog-header'
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent, 'horizontal')
|
|
self.set_padding(16)
|
|
|
|
|
|
class DialogTitle(QLabel, StyleMixin):
|
|
"""Dialog title."""
|
|
|
|
_base_class = 'dialog-title'
|
|
|
|
def __init__(self, text: str = '', parent=None):
|
|
super().__init__(text, parent)
|
|
|
|
font = self.font()
|
|
font.setPointSize(18)
|
|
font.setBold(True)
|
|
self.setFont(font)
|
|
|
|
|
|
class DialogContent(FakeMUIContainer):
|
|
"""Dialog content section."""
|
|
|
|
_base_class = 'dialog-content'
|
|
|
|
def __init__(self, parent=None, dividers: bool = False):
|
|
super().__init__(parent, 'vertical')
|
|
self.set_padding(16)
|
|
|
|
if dividers:
|
|
self.add_modifier('dividers')
|
|
|
|
|
|
class DialogActions(FakeMUIContainer):
|
|
"""Dialog actions section."""
|
|
|
|
_base_class = 'dialog-actions'
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent, 'horizontal')
|
|
self.set_padding(8)
|
|
self.set_spacing(8)
|
|
self._layout.addStretch() # Right-align buttons
|
|
|
|
|
|
class Popover(QFrame, StyleMixin):
|
|
"""Popover component for displaying content near an anchor."""
|
|
|
|
_base_class = 'popover'
|
|
|
|
onClose = pyqtSignal()
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
anchorEl: QWidget = None,
|
|
open: bool = False,
|
|
anchorOrigin: dict = None, # {'vertical': 'bottom', 'horizontal': 'left'}
|
|
transformOrigin: dict = None,
|
|
):
|
|
super().__init__(parent, Qt.WindowType.Popup)
|
|
|
|
self._anchor = anchorEl
|
|
self._anchor_origin = anchorOrigin or {'vertical': 'bottom', 'horizontal': 'left'}
|
|
|
|
self._layout = QVBoxLayout(self)
|
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
|
|
if open:
|
|
self.show_popover()
|
|
|
|
def add_widget(self, widget: QWidget):
|
|
"""Add widget to popover."""
|
|
self._layout.addWidget(widget)
|
|
|
|
def show_popover(self):
|
|
"""Show popover at anchor position."""
|
|
if self._anchor:
|
|
pos = self._anchor.mapToGlobal(QPoint(0, 0))
|
|
|
|
v = self._anchor_origin.get('vertical', 'bottom')
|
|
h = self._anchor_origin.get('horizontal', 'left')
|
|
|
|
if v == 'bottom':
|
|
pos.setY(pos.y() + self._anchor.height())
|
|
elif v == 'center':
|
|
pos.setY(pos.y() + self._anchor.height() // 2)
|
|
|
|
if h == 'right':
|
|
pos.setX(pos.x() + self._anchor.width())
|
|
elif h == 'center':
|
|
pos.setX(pos.x() + self._anchor.width() // 2)
|
|
|
|
self.move(pos)
|
|
|
|
self.show()
|
|
|
|
def close_popover(self):
|
|
"""Close popover."""
|
|
self.onClose.emit()
|
|
self.hide()
|
|
|
|
|
|
class Collapse(QFrame, StyleMixin):
|
|
"""Collapsible content component."""
|
|
|
|
_base_class = 'collapse'
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
expanded: bool = False,
|
|
collapsedSize: int = 0,
|
|
orientation: str = 'vertical',
|
|
):
|
|
super().__init__(parent)
|
|
|
|
self._expanded = expanded
|
|
self._collapsed_size = collapsedSize
|
|
self._orientation = orientation
|
|
|
|
self._layout = QVBoxLayout(self)
|
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self._content = QFrame()
|
|
self._content_layout = QVBoxLayout(self._content)
|
|
self._layout.addWidget(self._content)
|
|
|
|
# Animation
|
|
self._animation = QPropertyAnimation(self, b"maximumHeight")
|
|
self._animation.setDuration(300)
|
|
self._animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
|
|
|
if not expanded:
|
|
self.setMaximumHeight(collapsedSize)
|
|
|
|
def add_widget(self, widget: QWidget):
|
|
"""Add widget to collapsible content."""
|
|
self._content_layout.addWidget(widget)
|
|
|
|
def set_expanded(self, expanded: bool):
|
|
"""Set expanded state with animation."""
|
|
self._expanded = expanded
|
|
|
|
if expanded:
|
|
self._animation.setStartValue(self.height())
|
|
self._animation.setEndValue(self._content.sizeHint().height())
|
|
else:
|
|
self._animation.setStartValue(self.height())
|
|
self._animation.setEndValue(self._collapsed_size)
|
|
|
|
self._animation.start()
|
|
|
|
def toggle(self):
|
|
"""Toggle expanded state."""
|
|
self.set_expanded(not self._expanded)
|
|
|
|
|
|
class Fade(QWidget, StyleMixin):
|
|
"""Fade transition component."""
|
|
|
|
_base_class = 'fade'
|
|
|
|
def __init__(
|
|
self,
|
|
parent=None,
|
|
visible: bool = True,
|
|
duration: int = 225,
|
|
):
|
|
super().__init__(parent)
|
|
|
|
self._duration = duration
|
|
|
|
self._layout = QVBoxLayout(self)
|
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self._opacity_effect = QGraphicsOpacityEffect(self)
|
|
self.setGraphicsEffect(self._opacity_effect)
|
|
|
|
self._animation = QPropertyAnimation(self._opacity_effect, b"opacity")
|
|
self._animation.setDuration(duration)
|
|
self._animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
|
|
|
|
self._opacity_effect.setOpacity(1.0 if visible else 0.0)
|
|
|
|
def add_widget(self, widget: QWidget):
|
|
"""Add widget to fade container."""
|
|
self._layout.addWidget(widget)
|
|
|
|
def fade_in(self):
|
|
"""Fade in animation."""
|
|
self._animation.setStartValue(0.0)
|
|
self._animation.setEndValue(1.0)
|
|
self.show()
|
|
self._animation.start()
|
|
|
|
def fade_out(self):
|
|
"""Fade out animation."""
|
|
self._animation.setStartValue(1.0)
|
|
self._animation.setEndValue(0.0)
|
|
self._animation.finished.connect(self.hide)
|
|
self._animation.start()
|
|
|
|
|
|
class ClickAwayListener(QObject):
|
|
"""Detects clicks outside of a component."""
|
|
|
|
clickedAway = pyqtSignal()
|
|
|
|
def __init__(self, target: QWidget, parent=None):
|
|
super().__init__(parent)
|
|
|
|
self._target = target
|
|
QApplication.instance().installEventFilter(self)
|
|
|
|
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
|
"""Filter events to detect clicks outside target."""
|
|
if event.type() == QEvent.Type.MouseButtonPress:
|
|
if not self._target.geometry().contains(event.globalPosition().toPoint()):
|
|
self.clickedAway.emit()
|
|
return False
|
|
|
|
def stop(self):
|
|
"""Stop listening for clicks."""
|
|
QApplication.instance().removeEventFilter(self)
|
|
|
|
|
|
# Theme management
|
|
_current_theme = 'light'
|
|
_theme_colors = THEME_COLORS.copy()
|
|
|
|
|
|
def apply_theme(theme: str = 'light'):
|
|
"""Apply a theme to the application."""
|
|
global _current_theme, _theme_colors
|
|
|
|
_current_theme = theme
|
|
if theme == 'dark':
|
|
_theme_colors = DARK_THEME_COLORS.copy()
|
|
else:
|
|
_theme_colors = THEME_COLORS.copy()
|
|
|
|
# Generate and apply stylesheet
|
|
app = QApplication.instance()
|
|
if app:
|
|
stylesheet = _generate_stylesheet()
|
|
app.setStyleSheet(stylesheet)
|
|
|
|
|
|
def get_theme() -> str:
|
|
"""Get the current theme name."""
|
|
return _current_theme
|
|
|
|
|
|
def _generate_stylesheet() -> str:
|
|
"""Generate the complete stylesheet based on current theme."""
|
|
colors = _theme_colors
|
|
|
|
return f"""
|
|
/* Base styles */
|
|
QWidget {{
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
font-size: 14px;
|
|
color: {colors['text_primary']};
|
|
}}
|
|
|
|
/* Button styles */
|
|
QPushButton[objectName^="btn"] {{
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
border: none;
|
|
background-color: {colors['grey_200']};
|
|
}}
|
|
|
|
QPushButton[objectName*="--primary"] {{
|
|
background-color: {colors['primary']};
|
|
color: white;
|
|
}}
|
|
|
|
QPushButton[objectName*="--secondary"] {{
|
|
background-color: {colors['secondary']};
|
|
color: white;
|
|
}}
|
|
|
|
QPushButton[objectName*="--outline"] {{
|
|
background-color: transparent;
|
|
border: 1px solid {colors['primary']};
|
|
color: {colors['primary']};
|
|
}}
|
|
|
|
QPushButton[objectName*="--ghost"] {{
|
|
background-color: transparent;
|
|
color: {colors['primary']};
|
|
}}
|
|
|
|
QPushButton:hover {{
|
|
background-color: {colors['grey_300']};
|
|
}}
|
|
|
|
QPushButton[objectName*="--primary"]:hover {{
|
|
background-color: {colors['primary_dark']};
|
|
}}
|
|
|
|
QPushButton:disabled {{
|
|
background-color: {colors['grey_200']};
|
|
color: {colors['text_disabled']};
|
|
}}
|
|
|
|
/* Input styles */
|
|
QLineEdit[objectName^="input"], QTextEdit[objectName^="textarea"] {{
|
|
padding: 8px 12px;
|
|
border: 1px solid {colors['grey_400']};
|
|
border-radius: 4px;
|
|
background-color: {colors['background_paper']};
|
|
}}
|
|
|
|
QLineEdit[objectName*="--error"], QTextEdit[objectName*="--error"] {{
|
|
border-color: {colors['error']};
|
|
}}
|
|
|
|
QLineEdit:focus, QTextEdit:focus {{
|
|
border-color: {colors['primary']};
|
|
}}
|
|
|
|
/* Card styles */
|
|
QFrame[objectName^="card"] {{
|
|
background-color: {colors['background_paper']};
|
|
border-radius: 4px;
|
|
border: 1px solid {colors['divider']};
|
|
}}
|
|
|
|
QFrame[objectName*="--raised"] {{
|
|
border: none;
|
|
}}
|
|
|
|
/* Paper styles */
|
|
QFrame[objectName^="paper"] {{
|
|
background-color: {colors['background_paper']};
|
|
border-radius: 4px;
|
|
}}
|
|
|
|
/* Alert styles */
|
|
QFrame[objectName^="alert"] {{
|
|
padding: 12px;
|
|
border-radius: 4px;
|
|
}}
|
|
|
|
QFrame[objectName*="--info"] {{
|
|
background-color: {colors['info_light']};
|
|
color: {colors['info_dark']};
|
|
}}
|
|
|
|
QFrame[objectName*="--success"] {{
|
|
background-color: {colors['success_light']};
|
|
color: {colors['success_dark']};
|
|
}}
|
|
|
|
QFrame[objectName*="--warning"] {{
|
|
background-color: {colors['warning_light']};
|
|
color: {colors['warning_dark']};
|
|
}}
|
|
|
|
QFrame[objectName*="--error"] {{
|
|
background-color: {colors['error_light']};
|
|
color: {colors['error_dark']};
|
|
}}
|
|
|
|
/* Chip styles */
|
|
QPushButton[objectName^="chip"] {{
|
|
padding: 4px 12px;
|
|
border-radius: 16px;
|
|
}}
|
|
|
|
/* List styles */
|
|
QFrame[objectName^="list"] {{
|
|
background-color: {colors['background_paper']};
|
|
}}
|
|
|
|
QFrame[objectName^="list-item"]:hover {{
|
|
background-color: {colors['grey_100']};
|
|
}}
|
|
|
|
QFrame[objectName*="--selected"] {{
|
|
background-color: {colors['primary_light']};
|
|
}}
|
|
|
|
/* Tab styles */
|
|
QPushButton[objectName^="tab"] {{
|
|
padding: 12px 16px;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
background-color: transparent;
|
|
}}
|
|
|
|
QPushButton[objectName*="--active"] {{
|
|
border-bottom-color: {colors['primary']};
|
|
color: {colors['primary']};
|
|
}}
|
|
|
|
/* Divider */
|
|
QFrame[objectName^="divider"] {{
|
|
background-color: {colors['divider']};
|
|
}}
|
|
|
|
/* Typography */
|
|
QLabel[objectName*="--textSecondary"] {{
|
|
color: {colors['text_secondary']};
|
|
}}
|
|
|
|
QLabel[objectName*="--textDisabled"] {{
|
|
color: {colors['text_disabled']};
|
|
}}
|
|
|
|
/* Skeleton */
|
|
QFrame[objectName^="skeleton"] {{
|
|
background-color: {colors['grey_300']};
|
|
border-radius: 4px;
|
|
}}
|
|
|
|
/* Progress bar */
|
|
QProgressBar {{
|
|
background-color: {colors['grey_200']};
|
|
border: none;
|
|
border-radius: 2px;
|
|
}}
|
|
|
|
QProgressBar::chunk {{
|
|
background-color: {colors['primary']};
|
|
border-radius: 2px;
|
|
}}
|
|
|
|
/* Drawer */
|
|
QFrame[objectName^="drawer"] {{
|
|
background-color: {colors['background_paper']};
|
|
border-right: 1px solid {colors['divider']};
|
|
}}
|
|
|
|
/* AppBar */
|
|
QFrame[objectName^="app-bar"] {{
|
|
background-color: {colors['primary']};
|
|
color: white;
|
|
}}
|
|
"""
|
|
|
|
|
|
class ThemeProvider(QWidget):
|
|
"""Context provider for theming."""
|
|
|
|
def __init__(self, parent=None, theme: str = 'light'):
|
|
super().__init__(parent)
|
|
|
|
self._layout = QVBoxLayout(self)
|
|
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
apply_theme(theme)
|
|
|
|
def add_widget(self, widget: QWidget):
|
|
"""Add widget to theme provider."""
|
|
self._layout.addWidget(widget)
|
|
|
|
def set_theme(self, theme: str):
|
|
"""Change the theme."""
|
|
apply_theme(theme)
|