EU-Utility/core/modern_ui/__init__.py

869 lines
28 KiB
Python

"""
EU-Utility - Modern UI Design System
=====================================
A beautiful, modern design system for EU-Utility featuring:
- Dark gaming aesthetic with EU orange accents
- Glassmorphism effects throughout
- Smooth 60fps animations
- Responsive layouts
- Professional iconography
Design Principles:
1. Visual Hierarchy - Clear distinction between elements
2. Consistency - Unified design language
3. Feedback - Clear interactive states
4. Performance - GPU-accelerated animations
5. Accessibility - WCAG compliant contrast ratios
"""
from PyQt6.QtCore import Qt, QPropertyAnimation, QEasingCurve, QParallelAnimationGroup, QTimer
from PyQt6.QtCore import pyqtSignal, QSize, QPoint, QRectF
from PyQt6.QtWidgets import (
QWidget, QFrame, QPushButton, QLabel, QVBoxLayout, QHBoxLayout,
QGraphicsDropShadowEffect, QGraphicsOpacityEffect, QLineEdit,
QScrollArea, QStackedWidget, QProgressBar, QTextEdit, QComboBox
)
from PyQt6.QtGui import (
QColor, QPainter, QLinearGradient, QRadialGradient, QFont,
QFontDatabase, QIcon, QPixmap, QCursor, QPainterPath
)
from typing import Optional, Callable, List, Dict, Any
import math
# =============================================================================
# DESIGN TOKENS - Centralized Design System
# =============================================================================
class DesignTokens:
"""Central design tokens for consistent UI across the application."""
# Brand Colors
BRAND_ORANGE = "#FF6B35"
BRAND_ORANGE_LIGHT = "#FF8C5A"
BRAND_ORANGE_DARK = "#E55A2B"
BRAND_ORANGE_GLOW = "rgba(255, 107, 53, 0.4)"
# Extended Color Palette
COLORS = {
# Primary
'primary': '#FF6B35',
'primary_hover': '#FF8C5A',
'primary_pressed': '#E55A2B',
'primary_glow': 'rgba(255, 107, 53, 0.4)',
# Backgrounds - Deep space aesthetic
'bg_darkest': '#0A0C10',
'bg_dark': '#111318',
'bg_card': '#161920',
'bg_elevated': '#1D2129',
'bg_hover': '#252A33',
'bg_pressed': '#2D333D',
# Surfaces with glassmorphism
'surface': 'rgba(22, 25, 32, 0.85)',
'surface_hover': 'rgba(29, 33, 41, 0.9)',
'surface_active': 'rgba(37, 42, 51, 0.95)',
# Accents
'accent_teal': '#00D4AA',
'accent_blue': '#4D9CFF',
'accent_purple': '#A855F7',
'accent_yellow': '#FBBF24',
'accent_green': '#22C55E',
'accent_red': '#EF4444',
# Text
'text_primary': '#F0F4F8',
'text_secondary': '#9CA3AF',
'text_muted': '#6B7280',
'text_disabled': '#4B5563',
# Borders
'border_subtle': 'rgba(255, 255, 255, 0.06)',
'border_default': 'rgba(255, 255, 255, 0.1)',
'border_hover': 'rgba(255, 255, 255, 0.15)',
'border_focus': 'rgba(255, 107, 53, 0.5)',
'border_active': 'rgba(255, 107, 53, 0.8)',
# Status
'success': '#22C55E',
'warning': '#FBBF24',
'error': '#EF4444',
'info': '#4D9CFF',
}
# Typography
TYPOGRAPHY = {
'font_family': '"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif',
'font_mono': '"JetBrains Mono", "Fira Code", monospace',
'size_xs': 11,
'size_sm': 12,
'size_base': 13,
'size_md': 14,
'size_lg': 16,
'size_xl': 18,
'size_2xl': 20,
'size_3xl': 24,
'size_4xl': 30,
'size_5xl': 36,
}
# Spacing (4px grid system)
SPACING = {
'0': 0,
'1': 4,
'2': 8,
'3': 12,
'4': 16,
'5': 20,
'6': 24,
'8': 32,
'10': 40,
'12': 48,
'16': 64,
}
# Border Radius
RADIUS = {
'none': 0,
'sm': 4,
'md': 8,
'lg': 12,
'xl': 16,
'2xl': 20,
'3xl': 24,
'full': 9999,
}
# Shadows
SHADOWS = {
'sm': '0 1px 2px rgba(0, 0, 0, 0.3)',
'md': '0 4px 6px rgba(0, 0, 0, 0.4)',
'lg': '0 10px 15px rgba(0, 0, 0, 0.5)',
'xl': '0 20px 25px rgba(0, 0, 0, 0.6)',
'glow': '0 0 20px rgba(255, 107, 53, 0.3)',
'glow_strong': '0 0 30px rgba(255, 107, 53, 0.5)',
}
# Animation Durations (ms)
DURATION = {
'fast': 150,
'normal': 250,
'slow': 350,
'slower': 500,
}
# Easing Curves
EASING = {
'default': QEasingCurve.Type.OutCubic,
'bounce': QEasingCurve.Type.OutBounce,
'elastic': QEasingCurve.Type.OutElastic,
'smooth': QEasingCurve.Type.InOutCubic,
}
@classmethod
def color(cls, name: str) -> str:
"""Get color by name."""
return cls.COLORS.get(name, '#FFFFFF')
@classmethod
def spacing(cls, name: str) -> int:
"""Get spacing value."""
return cls.SPACING.get(name, 0)
@classmethod
def radius(cls, name: str) -> int:
"""Get border radius value."""
return cls.RADIUS.get(name, 0)
# =============================================================================
# ANIMATION UTILITIES
# =============================================================================
class AnimationManager:
"""Manages smooth GPU-accelerated animations."""
_active_animations: List[QPropertyAnimation] = []
@classmethod
def fade_in(cls, widget: QWidget, duration: int = 250) -> QPropertyAnimation:
"""Fade in a widget smoothly."""
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
anim = QPropertyAnimation(effect, b"opacity")
anim.setDuration(duration)
anim.setStartValue(0.0)
anim.setEndValue(1.0)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
cls._active_animations.append(anim)
anim.finished.connect(lambda: cls._cleanup_animation(anim))
return anim
@classmethod
def fade_out(cls, widget: QWidget, duration: int = 200, on_finish: Optional[Callable] = None) -> QPropertyAnimation:
"""Fade out a widget smoothly."""
effect = widget.graphicsEffect()
if not isinstance(effect, QGraphicsOpacityEffect):
effect = QGraphicsOpacityEffect(widget)
widget.setGraphicsEffect(effect)
anim = QPropertyAnimation(effect, b"opacity")
anim.setDuration(duration)
anim.setStartValue(1.0)
anim.setEndValue(0.0)
anim.setEasingCurve(QEasingCurve.Type.InCubic)
if on_finish:
anim.finished.connect(on_finish)
cls._active_animations.append(anim)
anim.finished.connect(lambda: cls._cleanup_animation(anim))
return anim
@classmethod
def slide_in(cls, widget: QWidget, direction: str = "bottom", duration: int = 300) -> QPropertyAnimation:
"""Slide widget in from specified direction."""
anim = QPropertyAnimation(widget, b"pos")
anim.setDuration(duration)
anim.setEasingCurve(QEasingCurve.Type.OutCubic)
current_pos = widget.pos()
if direction == "left":
start_pos = current_pos - QPoint(widget.width() + 20, 0)
elif direction == "right":
start_pos = current_pos + QPoint(widget.width() + 20, 0)
elif direction == "top":
start_pos = current_pos - QPoint(0, widget.height() + 20)
else: # bottom
start_pos = current_pos + QPoint(0, widget.height() + 20)
anim.setStartValue(start_pos)
anim.setEndValue(current_pos)
cls._active_animations.append(anim)
anim.finished.connect(lambda: cls._cleanup_animation(anim))
return anim
@classmethod
def scale(cls, widget: QWidget, from_scale: float = 0.9, to_scale: float = 1.0, duration: int = 250) -> QPropertyAnimation:
"""Scale animation for widgets."""
anim = QPropertyAnimation(widget, b"minimumWidth")
anim.setDuration(duration)
anim.setEasingCurve(QEasingCurve.Type.OutBack)
base_width = widget.width()
anim.setStartValue(int(base_width * from_scale))
anim.setEndValue(int(base_width * to_scale))
cls._active_animations.append(anim)
anim.finished.connect(lambda: cls._cleanup_animation(anim))
return anim
@classmethod
def pulse_glow(cls, widget: QWidget, duration: int = 2000) -> QPropertyAnimation:
"""Create a pulsing glow effect."""
effect = QGraphicsDropShadowEffect(widget)
effect.setColor(QColor(255, 107, 53))
effect.setBlurRadius(20)
effect.setOffset(0, 0)
widget.setGraphicsEffect(effect)
anim = QPropertyAnimation(effect, b"blurRadius")
anim.setDuration(duration)
anim.setStartValue(20)
anim.setEndValue(40)
anim.setEasingCurve(QEasingCurve.Type.InOutSine)
anim.setLoopCount(-1)
return anim
@classmethod
def _cleanup_animation(cls, anim: QPropertyAnimation):
"""Remove completed animation from tracking."""
if anim in cls._active_animations:
cls._active_animations.remove(anim)
# =============================================================================
# MODERN COMPONENTS
# =============================================================================
class GlassCard(QFrame):
"""Glassmorphism card with frosted glass effect."""
clicked = pyqtSignal()
def __init__(self, parent=None, elevation: int = 1, hover_lift: bool = True):
super().__init__(parent)
self.elevation = elevation
self.hover_lift = hover_lift
self._hovered = False
self._setup_style()
self._setup_shadow()
if hover_lift:
self.setMouseTracking(True)
def _setup_style(self):
"""Apply glassmorphism styling."""
c = DesignTokens.COLORS
opacity = 0.85 + (self.elevation * 0.03)
self.setStyleSheet(f"""
GlassCard {{
background: rgba(22, 25, 32, {min(opacity, 0.95)});
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: {DesignTokens.radius('xl')}px;
}}
""")
def _setup_shadow(self):
"""Apply elevation shadow."""
self._shadow = QGraphicsDropShadowEffect(self)
self._shadow.setBlurRadius(self.elevation * 15)
self._shadow.setColor(QColor(0, 0, 0, int(80 + self.elevation * 20)))
self._shadow.setOffset(0, self.elevation * 3)
self.setGraphicsEffect(self._shadow)
def enterEvent(self, event):
"""Hover effect."""
if self.hover_lift:
self._hovered = True
self._shadow.setBlurRadius((self.elevation + 1) * 15)
self._shadow.setColor(QColor(0, 0, 0, int(100 + (self.elevation + 1) * 20)))
self.setStyleSheet(f"""
GlassCard {{
background: rgba(29, 33, 41, 0.9);
border: 1px solid rgba(255, 107, 53, 0.2);
border-radius: {DesignTokens.radius('xl')}px;
}}
""")
super().enterEvent(event)
def leaveEvent(self, event):
"""Reset hover effect."""
if self.hover_lift:
self._hovered = False
self._setup_shadow()
self._setup_style()
super().leaveEvent(event)
def mousePressEvent(self, event):
"""Handle click."""
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
super().mousePressEvent(event)
class ModernButton(QPushButton):
"""Modern button with smooth animations and multiple variants."""
VARIANTS = ['primary', 'secondary', 'ghost', 'outline', 'danger', 'glass']
def __init__(self, text: str = "", variant: str = 'primary', icon: str = None, parent=None):
super().__init__(text, parent)
self.variant = variant
self.icon_text = icon
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self._apply_style()
self._setup_animations()
def _apply_style(self):
"""Apply button styling based on variant."""
c = DesignTokens.COLORS
r = DesignTokens.radius('full')
styles = {
'primary': f"""
ModernButton {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {c['primary']}, stop:1 {c['primary_hover']});
color: white;
border: none;
border-radius: {r}px;
padding: 12px 24px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
font-weight: 600;
}}
ModernButton:hover {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {c['primary_hover']}, stop:1 {c['primary']});
}}
ModernButton:pressed {{
background: {c['primary_pressed']};
}}
""",
'secondary': f"""
ModernButton {{
background: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {r}px;
padding: 12px 24px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
font-weight: 600;
}}
ModernButton:hover {{
background: {c['bg_hover']};
border-color: {c['border_hover']};
}}
""",
'ghost': f"""
ModernButton {{
background: transparent;
color: {c['text_secondary']};
border: none;
border-radius: {r}px;
padding: 12px 24px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
font-weight: 500;
}}
ModernButton:hover {{
background: rgba(255, 255, 255, 0.05);
color: {c['text_primary']};
}}
""",
'outline': f"""
ModernButton {{
background: transparent;
color: {c['primary']};
border: 2px solid {c['primary']};
border-radius: {r}px;
padding: 10px 22px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
font-weight: 600;
}}
ModernButton:hover {{
background: rgba(255, 107, 53, 0.1);
}}
""",
'danger': f"""
ModernButton {{
background: {c['error']};
color: white;
border: none;
border-radius: {r}px;
padding: 12px 24px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
font-weight: 600;
}}
ModernButton:hover {{
background: #DC2626;
}}
""",
'glass': f"""
ModernButton {{
background: rgba(255, 255, 255, 0.08);
color: white;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: {r}px;
padding: 12px 24px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
font-weight: 600;
backdrop-filter: blur(10px);
}}
ModernButton:hover {{
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 107, 53, 0.3);
}}
"""
}
self.setStyleSheet(styles.get(self.variant, styles['primary']))
self.setFixedHeight(44)
def _setup_animations(self):
"""Setup press animation."""
self._press_anim = QPropertyAnimation(self, b"minimumHeight")
self._press_anim.setDuration(100)
self._press_anim.setEasingCurve(QEasingCurve.Type.OutQuad)
def enterEvent(self, event):
"""Hover animation."""
super().enterEvent(event)
def mousePressEvent(self, event):
"""Press animation."""
if event.button() == Qt.MouseButton.LeftButton:
self._press_anim.setStartValue(44)
self._press_anim.setEndValue(42)
self._press_anim.start()
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
"""Release animation."""
self._press_anim.setStartValue(42)
self._press_anim.setEndValue(44)
self._press_anim.start()
super().mouseReleaseEvent(event)
class ModernInput(QLineEdit):
"""Modern input field with floating label and animations."""
def __init__(self, placeholder: str = "", parent=None):
super().__init__(parent)
self.setPlaceholderText(placeholder)
self._apply_style()
def _apply_style(self):
"""Apply modern input styling."""
c = DesignTokens.COLORS
r = DesignTokens.radius('lg')
self.setStyleSheet(f"""
ModernInput {{
background: {c['bg_elevated']};
color: {c['text_primary']};
border: 2px solid {c['border_default']};
border-radius: {r}px;
padding: 12px 16px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
selection-background-color: {c['primary']};
}}
ModernInput:hover {{
border-color: {c['border_hover']};
}}
ModernInput:focus {{
border-color: {c['primary']};
background: {c['bg_card']};
}}
ModernInput::placeholder {{
color: {c['text_muted']};
}}
""")
self.setFixedHeight(48)
class ModernComboBox(QComboBox):
"""Modern dropdown with custom styling."""
def __init__(self, parent=None):
super().__init__(parent)
self._apply_style()
def _apply_style(self):
"""Apply modern combobox styling."""
c = DesignTokens.COLORS
r = DesignTokens.radius('lg')
self.setStyleSheet(f"""
ModernComboBox {{
background: {c['bg_elevated']};
color: {c['text_primary']};
border: 2px solid {c['border_default']};
border-radius: {r}px;
padding: 8px 12px;
font-size: {DesignTokens.TYPOGRAPHY['size_md']}px;
min-width: 150px;
}}
ModernComboBox:hover {{
border-color: {c['border_hover']};
}}
ModernComboBox:focus {{
border-color: {c['primary']};
}}
ModernComboBox::drop-down {{
border: none;
width: 30px;
}}
ModernComboBox::down-arrow {{
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid {c['text_secondary']};
}}
ModernComboBox QAbstractItemView {{
background: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: {r}px;
selection-background-color: {c['bg_hover']};
selection-color: {c['text_primary']};
padding: 4px;
}}
""")
self.setFixedHeight(48)
class Badge(QLabel):
"""Status badge with various styles."""
STYLES = ['default', 'success', 'warning', 'error', 'info', 'primary']
def __init__(self, text: str = "", style: str = 'default', parent=None):
super().__init__(text, parent)
self.badge_style = style
self._apply_style()
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
def _apply_style(self):
"""Apply badge styling."""
c = DesignTokens.COLORS
colors = {
'default': ('rgba(255,255,255,0.1)', c['text_secondary']),
'success': ('rgba(34, 197, 94, 0.2)', c['accent_green']),
'warning': ('rgba(251, 191, 36, 0.2)', c['accent_yellow']),
'error': ('rgba(239, 68, 68, 0.2)', c['accent_red']),
'info': ('rgba(77, 156, 255, 0.2)', c['accent_blue']),
'primary': ('rgba(255, 107, 53, 0.2)', c['primary']),
}
bg, fg = colors.get(self.badge_style, colors['default'])
self.setStyleSheet(f"""
Badge {{
background: {bg};
color: {fg};
border-radius: {DesignTokens.radius('full')}px;
padding: 4px 12px;
font-size: {DesignTokens.TYPOGRAPHY['size_xs']}px;
font-weight: 600;
}}
""")
# Add subtle shadow
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(4)
shadow.setColor(QColor(0, 0, 0, 40))
shadow.setOffset(0, 1)
self.setGraphicsEffect(shadow)
class ProgressIndicator(QProgressBar):
"""Modern progress indicator with gradient."""
def __init__(self, parent=None):
super().__init__(parent)
self._apply_style()
self.setTextVisible(False)
self.setRange(0, 100)
self.setValue(0)
def _apply_style(self):
"""Apply modern progress styling."""
c = DesignTokens.COLORS
self.setStyleSheet(f"""
ProgressIndicator {{
background: {c['bg_elevated']};
border: none;
border-radius: {DesignTokens.radius('full')}px;
height: 6px;
}}
ProgressIndicator::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {c['primary']}, stop:1 {c['primary_hover']});
border-radius: {DesignTokens.radius('full')}px;
}}
""")
self.setFixedHeight(6)
class IconButton(QPushButton):
"""Circular icon button with hover effects."""
def __init__(self, icon_text: str = "", size: int = 40, tooltip: str = "", parent=None):
super().__init__(icon_text, parent)
self.setFixedSize(size, size)
self.setToolTip(tooltip)
self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor))
self._apply_style()
def _apply_style(self):
"""Apply icon button styling."""
c = DesignTokens.COLORS
size = self.width()
self.setStyleSheet(f"""
IconButton {{
background: transparent;
color: {c['text_secondary']};
border: none;
border-radius: {size // 2}px;
font-size: 16px;
}}
IconButton:hover {{
background: rgba(255, 255, 255, 0.1);
color: {c['text_primary']};
}}
IconButton:pressed {{
background: rgba(255, 255, 255, 0.15);
}}
""")
# =============================================================================
# LAYOUT HELPERS
# =============================================================================
def create_spacer(horizontal: bool = False, size: int = None):
"""Create a spacer item."""
from PyQt6.QtWidgets import QSpacerItem, QSizePolicy
if horizontal:
policy = QSizePolicy.Policy.Expanding
min_policy = QSizePolicy.Policy.Minimum
return QSpacerItem(size or 0, 0, policy, min_policy)
else:
policy = QSizePolicy.Policy.Expanding
min_policy = QSizePolicy.Policy.Minimum
return QSpacerItem(0, size or 0, min_policy, policy)
def create_separator(horizontal: bool = True):
"""Create a styled separator line."""
separator = QFrame()
if horizontal:
separator.setFrameShape(QFrame.Shape.HLine)
separator.setFixedHeight(1)
else:
separator.setFrameShape(QFrame.Shape.VLine)
separator.setFixedWidth(1)
separator.setStyleSheet(f"""
background: {DesignTokens.color('border_subtle')};
""")
return separator
# =============================================================================
# GLOBAL STYLESHEET
# =============================================================================
def get_global_stylesheet() -> str:
"""Get complete global stylesheet for the application."""
c = DesignTokens.COLORS
return f"""
/* Base */
QWidget {{
font-family: {DesignTokens.TYPOGRAPHY['font_family']};
font-size: {DesignTokens.TYPOGRAPHY['size_base']}px;
color: {c['text_primary']};
}}
/* Main Window */
QMainWindow {{
background: {c['bg_darkest']};
}}
/* Selection */
::selection {{
background: {c['primary']};
color: white;
}}
/* Scrollbars */
QScrollBar:vertical {{
background: transparent;
width: 8px;
margin: 0;
}}
QScrollBar::handle:vertical {{
background: {c['border_default']};
border-radius: 4px;
min-height: 40px;
}}
QScrollBar::handle:vertical:hover {{
background: {c['border_hover']};
}}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {{
height: 0;
}}
QScrollBar:horizontal {{
background: transparent;
height: 8px;
margin: 0;
}}
QScrollBar::handle:horizontal {{
background: {c['border_default']};
border-radius: 4px;
min-width: 40px;
}}
/* Tooltips */
QToolTip {{
background: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: 8px;
padding: 8px 12px;
font-size: {DesignTokens.TYPOGRAPHY['size_sm']}px;
}}
/* Menu */
QMenu {{
background: {c['bg_elevated']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: 12px;
padding: 8px;
}}
QMenu::item {{
padding: 10px 20px;
border-radius: 8px;
}}
QMenu::item:selected {{
background: {c['bg_hover']};
}}
QMenu::separator {{
height: 1px;
background: {c['border_subtle']};
margin: 8px 0;
}}
/* Check Box */
QCheckBox {{
spacing: 8px;
}}
QCheckBox::indicator {{
width: 20px;
height: 20px;
border: 2px solid {c['border_default']};
border-radius: 6px;
background: {c['bg_elevated']};
}}
QCheckBox::indicator:hover {{
border-color: {c['border_hover']};
}}
QCheckBox::indicator:checked {{
background: {c['primary']};
border-color: {c['primary']};
}}
/* Slider */
QSlider::groove:horizontal {{
height: 4px;
background: {c['bg_elevated']};
border-radius: 2px;
}}
QSlider::handle:horizontal {{
width: 18px;
height: 18px;
background: {c['primary']};
border-radius: 9px;
margin: -7px 0;
}}
QSlider::sub-page:horizontal {{
background: {c['primary']};
border-radius: 2px;
}}
"""