Files
metabuilder/python/fakemui/lab.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

695 lines
21 KiB
Python

"""
FakeMUI Lab Module for PyQt6
Provides experimental/lab components like LoadingButton, Timeline, TreeView, Masonry.
"""
from typing import Any, Callable, Dict, List, Optional, Union
from PyQt6.QtWidgets import (
QWidget, QPushButton, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QTreeWidget, QTreeWidgetItem, QGridLayout, QSizePolicy,
QScrollArea
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve
from PyQt6.QtGui import QFont, QColor
from .base import FakeMUIWidget, StyleMixin
# =============================================================================
# LoadingButton
# =============================================================================
class LoadingButton(QPushButton, StyleMixin):
"""
A button with loading state.
Properties:
loading: Whether the button is in loading state.
loading_position: Position of loading indicator ('start', 'center', 'end').
variant: Button style ('contained', 'outlined', 'text').
color: Button color ('primary', 'secondary', 'error', etc.).
"""
def __init__(
self,
text: str = "",
parent: Optional[QWidget] = None,
loading: bool = False,
loading_position: str = "center",
variant: str = "contained",
color: str = "primary",
size: str = "medium",
full_width: bool = False,
):
super().__init__(text, parent)
StyleMixin.__init__(self)
self._loading = loading
self._loading_position = loading_position
self._original_text = text
self._variant = variant
self._color = color
self._size = size
self._full_width = full_width
self._spinner_angle = 0
self._spinner_timer = QTimer()
self._spinner_timer.timeout.connect(self._update_spinner)
self._apply_classes()
self._update_loading_state()
def _apply_classes(self):
"""Apply CSS classes based on properties."""
self.clear_classes()
self.add_class("fakemui-loading-button")
self.add_class(f"fakemui-loading-button-{self._variant}")
self.add_class(f"fakemui-loading-button-{self._color}")
self.add_class(f"fakemui-loading-button-{self._size}")
if self._full_width:
self.add_class("fakemui-loading-button-fullwidth")
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
if self._loading:
self.add_class("fakemui-loading-button-loading")
self.setEnabled(False)
self._apply_stylesheet()
def _apply_stylesheet(self):
"""Apply styles based on variant and color."""
colors = {
"primary": ("#1976d2", "#1565c0", "#fff"),
"secondary": ("#9c27b0", "#7b1fa2", "#fff"),
"error": ("#d32f2f", "#c62828", "#fff"),
"warning": ("#ed6c02", "#e65100", "#fff"),
"info": ("#0288d1", "#01579b", "#fff"),
"success": ("#2e7d32", "#1b5e20", "#fff"),
}
sizes = {
"small": ("6px 12px", "12px"),
"medium": ("8px 16px", "14px"),
"large": ("10px 20px", "16px"),
}
main_color, dark_color, text_color = colors.get(self._color, colors["primary"])
padding, font_size = sizes.get(self._size, sizes["medium"])
if self._variant == "contained":
self.setStyleSheet(f"""
QPushButton {{
background-color: {main_color};
color: {text_color};
border: none;
border-radius: 4px;
padding: {padding};
font-size: {font_size};
font-weight: 500;
text-transform: uppercase;
}}
QPushButton:hover {{
background-color: {dark_color};
}}
QPushButton:disabled {{
background-color: #e0e0e0;
color: #9e9e9e;
}}
""")
elif self._variant == "outlined":
self.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {main_color};
border: 1px solid {main_color};
border-radius: 4px;
padding: {padding};
font-size: {font_size};
font-weight: 500;
text-transform: uppercase;
}}
QPushButton:hover {{
background-color: rgba(25, 118, 210, 0.04);
}}
QPushButton:disabled {{
border-color: #e0e0e0;
color: #9e9e9e;
}}
""")
else: # text
self.setStyleSheet(f"""
QPushButton {{
background-color: transparent;
color: {main_color};
border: none;
border-radius: 4px;
padding: {padding};
font-size: {font_size};
font-weight: 500;
text-transform: uppercase;
}}
QPushButton:hover {{
background-color: rgba(25, 118, 210, 0.04);
}}
QPushButton:disabled {{
color: #9e9e9e;
}}
""")
def _update_loading_state(self):
"""Update button text based on loading state."""
if self._loading:
if self._loading_position == "center":
self.setText("⟳ Loading...")
elif self._loading_position == "start":
self.setText(f"{self._original_text}")
else: # end
self.setText(f"{self._original_text}")
self._spinner_timer.start(100)
else:
self.setText(self._original_text)
self._spinner_timer.stop()
def _update_spinner(self):
"""Update spinner animation."""
spinners = ["", "", "", ""]
self._spinner_angle = (self._spinner_angle + 1) % 4
spinner = spinners[self._spinner_angle]
if self._loading_position == "center":
self.setText(f"{spinner} Loading...")
elif self._loading_position == "start":
self.setText(f"{spinner} {self._original_text}")
else:
self.setText(f"{self._original_text} {spinner}")
@property
def loading(self) -> bool:
return self._loading
@loading.setter
def loading(self, value: bool):
self._loading = value
self.setEnabled(not value)
self._apply_classes()
self._update_loading_state()
def set_loading(self, loading: bool):
"""Set loading state."""
self.loading = loading
# =============================================================================
# Timeline Components
# =============================================================================
class Timeline(QFrame, StyleMixin):
"""
Displays a list of events in chronological order.
Properties:
position: Timeline position ('left', 'right', 'alternate').
"""
def __init__(
self,
parent: Optional[QWidget] = None,
position: str = "right",
):
super().__init__(parent)
StyleMixin.__init__(self)
self._position = position
self._layout = QVBoxLayout(self)
self._layout.setSpacing(0)
self._layout.setContentsMargins(0, 0, 0, 0)
self.add_class("fakemui-timeline")
self.add_class(f"fakemui-timeline-position-{position}")
self.setStyleSheet("""
QFrame {
background: transparent;
}
""")
def add_item(self, item: 'TimelineItem'):
"""Add a timeline item."""
self._layout.addWidget(item)
class TimelineItem(QFrame, StyleMixin):
"""A single event in the timeline."""
def __init__(
self,
parent: Optional[QWidget] = None,
position: Optional[str] = None,
):
super().__init__(parent)
StyleMixin.__init__(self)
self._position = position
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(8, 8, 8, 8)
self._opposite_content: Optional[QWidget] = None
self._separator: Optional[QWidget] = None
self._content: Optional[QWidget] = None
self.add_class("fakemui-timeline-item")
if position:
self.add_class(f"fakemui-timeline-item-position-{position}")
self.setStyleSheet("""
QFrame {
background: transparent;
}
""")
def set_opposite_content(self, widget: QWidget):
"""Set the opposite content."""
self._opposite_content = widget
self._rebuild_layout()
def set_separator(self, widget: QWidget):
"""Set the separator."""
self._separator = widget
self._rebuild_layout()
def set_content(self, widget: QWidget):
"""Set the main content."""
self._content = widget
self._rebuild_layout()
def _rebuild_layout(self):
"""Rebuild the layout with current components."""
# Clear layout
while self._layout.count():
item = self._layout.takeAt(0)
if item.widget():
item.widget().setParent(None)
# Add components in order
if self._opposite_content:
self._layout.addWidget(self._opposite_content)
if self._separator:
self._layout.addWidget(self._separator)
if self._content:
self._layout.addWidget(self._content, 1)
class TimelineSeparator(QFrame, StyleMixin):
"""The separator between content and opposite content."""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
StyleMixin.__init__(self)
self._layout = QVBoxLayout(self)
self._layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._layout.setContentsMargins(8, 0, 8, 0)
self.add_class("fakemui-timeline-separator")
self.setFixedWidth(40)
def add_widget(self, widget: QWidget):
"""Add a widget to the separator."""
self._layout.addWidget(widget)
class TimelineConnector(QFrame, StyleMixin):
"""The vertical line connecting timeline items."""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
StyleMixin.__init__(self)
self.add_class("fakemui-timeline-connector")
self.setFixedWidth(2)
self.setMinimumHeight(20)
self.setStyleSheet("""
QFrame {
background-color: #bdbdbd;
}
""")
class TimelineContent(QFrame, StyleMixin):
"""The main content of a timeline item."""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
StyleMixin.__init__(self)
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(8, 8, 8, 8)
self.add_class("fakemui-timeline-content")
def add_widget(self, widget: QWidget):
"""Add a widget to the content."""
self._layout.addWidget(widget)
def set_text(self, text: str):
"""Set text content."""
label = QLabel(text)
label.setWordWrap(True)
self._layout.addWidget(label)
class TimelineDot(QFrame, StyleMixin):
"""The dot/icon indicator in the timeline."""
def __init__(
self,
parent: Optional[QWidget] = None,
color: str = "grey",
variant: str = "filled",
):
super().__init__(parent)
StyleMixin.__init__(self)
self._color = color
self._variant = variant
self.add_class("fakemui-timeline-dot")
self.add_class(f"fakemui-timeline-dot-{variant}")
self.add_class(f"fakemui-timeline-dot-{color}")
self.setFixedSize(12, 12)
colors = {
"grey": "#bdbdbd",
"primary": "#1976d2",
"secondary": "#9c27b0",
"error": "#d32f2f",
"warning": "#ed6c02",
"info": "#0288d1",
"success": "#2e7d32",
}
bg_color = colors.get(color, colors["grey"])
if variant == "filled":
self.setStyleSheet(f"""
QFrame {{
background-color: {bg_color};
border-radius: 6px;
}}
""")
else: # outlined
self.setStyleSheet(f"""
QFrame {{
background-color: transparent;
border: 2px solid {bg_color};
border-radius: 6px;
}}
""")
class TimelineOppositeContent(QFrame, StyleMixin):
"""Content on the opposite side of the timeline."""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
StyleMixin.__init__(self)
self._layout = QVBoxLayout(self)
self._layout.setContentsMargins(8, 8, 8, 8)
self._layout.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
self.add_class("fakemui-timeline-opposite-content")
def add_widget(self, widget: QWidget):
"""Add a widget."""
self._layout.addWidget(widget)
def set_text(self, text: str):
"""Set text content."""
label = QLabel(text)
label.setStyleSheet("color: rgba(0, 0, 0, 0.6);")
self._layout.addWidget(label)
# =============================================================================
# TreeView Components
# =============================================================================
class TreeView(QTreeWidget, StyleMixin):
"""
A hierarchical list component.
Signals:
node_toggled: Emitted when a node is expanded/collapsed.
node_selected: Emitted when a node is selected.
"""
node_toggled = pyqtSignal(str, bool) # node_id, is_expanded
node_selected = pyqtSignal(str) # node_id
def __init__(
self,
parent: Optional[QWidget] = None,
multi_select: bool = False,
default_expand_icon: str = "",
default_collapse_icon: str = "",
default_end_icon: str = "",
):
super().__init__(parent)
StyleMixin.__init__(self)
self._multi_select = multi_select
self._default_expand_icon = default_expand_icon
self._default_collapse_icon = default_collapse_icon
self._default_end_icon = default_end_icon
self._node_map: Dict[str, QTreeWidgetItem] = {}
self.setHeaderHidden(True)
self.setIndentation(20)
if multi_select:
self.setSelectionMode(QTreeWidget.SelectionMode.MultiSelection)
else:
self.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection)
self.itemExpanded.connect(self._on_item_expanded)
self.itemCollapsed.connect(self._on_item_collapsed)
self.itemClicked.connect(self._on_item_clicked)
self.add_class("fakemui-tree-view")
self.setStyleSheet("""
QTreeWidget {
background-color: transparent;
border: none;
outline: none;
}
QTreeWidget::item {
padding: 4px;
border-radius: 4px;
}
QTreeWidget::item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
QTreeWidget::item:selected {
background-color: rgba(25, 118, 210, 0.12);
color: #1976d2;
}
QTreeWidget::branch {
background: transparent;
}
""")
def add_node(
self,
node_id: str,
label: str,
parent_id: Optional[str] = None,
icon: Optional[str] = None,
) -> 'TreeItem':
"""Add a node to the tree."""
item = TreeItem(node_id, label, icon)
if parent_id and parent_id in self._node_map:
self._node_map[parent_id].addChild(item)
else:
self.addTopLevelItem(item)
self._node_map[node_id] = item
return item
def expand_node(self, node_id: str):
"""Expand a node."""
if node_id in self._node_map:
self._node_map[node_id].setExpanded(True)
def collapse_node(self, node_id: str):
"""Collapse a node."""
if node_id in self._node_map:
self._node_map[node_id].setExpanded(False)
def select_node(self, node_id: str):
"""Select a node."""
if node_id in self._node_map:
self._node_map[node_id].setSelected(True)
def _on_item_expanded(self, item: QTreeWidgetItem):
"""Handle item expansion."""
if isinstance(item, TreeItem):
self.node_toggled.emit(item.node_id, True)
def _on_item_collapsed(self, item: QTreeWidgetItem):
"""Handle item collapse."""
if isinstance(item, TreeItem):
self.node_toggled.emit(item.node_id, False)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int):
"""Handle item click."""
if isinstance(item, TreeItem):
self.node_selected.emit(item.node_id)
class TreeItem(QTreeWidgetItem):
"""A single item in the tree."""
def __init__(
self,
node_id: str,
label: str,
icon: Optional[str] = None,
):
super().__init__()
self.node_id = node_id
self._label = label
self._icon = icon
display_text = f"{icon} {label}" if icon else label
self.setText(0, display_text)
# =============================================================================
# Masonry
# =============================================================================
class Masonry(QScrollArea, StyleMixin):
"""
CSS-like masonry layout using a grid.
Properties:
columns: Number of columns.
spacing: Spacing between items (in 8px units).
"""
def __init__(
self,
parent: Optional[QWidget] = None,
columns: int = 4,
spacing: int = 1,
):
super().__init__(parent)
StyleMixin.__init__(self)
self._columns = columns
self._spacing = spacing
self._items: List[QWidget] = []
# Create container
self._container = QWidget()
self._layout = QGridLayout(self._container)
self._layout.setSpacing(spacing * 8)
self._layout.setContentsMargins(0, 0, 0, 0)
self.setWidget(self._container)
self.setWidgetResizable(True)
self.setFrameShape(QFrame.Shape.NoFrame)
self.add_class("fakemui-masonry")
self.setStyleSheet("""
QScrollArea {
background: transparent;
border: none;
}
""")
def add_item(self, widget: QWidget):
"""Add an item to the masonry layout."""
self._items.append(widget)
self._rebuild_layout()
def remove_item(self, widget: QWidget):
"""Remove an item from the masonry layout."""
if widget in self._items:
self._items.remove(widget)
widget.setParent(None)
self._rebuild_layout()
def clear(self):
"""Clear all items."""
for item in self._items:
item.setParent(None)
self._items.clear()
self._rebuild_layout()
def _rebuild_layout(self):
"""Rebuild the masonry layout."""
# Clear layout
while self._layout.count():
item = self._layout.takeAt(0)
# Add items in column-major order for masonry effect
col_heights = [0] * self._columns
for widget in self._items:
# Find the shortest column
min_col = col_heights.index(min(col_heights))
row = col_heights[min_col]
self._layout.addWidget(widget, row, min_col)
col_heights[min_col] += 1
@property
def columns(self) -> int:
return self._columns
@columns.setter
def columns(self, value: int):
self._columns = value
self._rebuild_layout()
@property
def spacing(self) -> int:
return self._spacing
@spacing.setter
def spacing(self, value: int):
self._spacing = value
self._layout.setSpacing(value * 8)
# =============================================================================
# Exports
# =============================================================================
__all__ = [
# Loading Button
"LoadingButton",
# Timeline
"Timeline",
"TimelineItem",
"TimelineSeparator",
"TimelineConnector",
"TimelineContent",
"TimelineDot",
"TimelineOppositeContent",
# TreeView
"TreeView",
"TreeItem",
# Masonry
"Masonry",
]