""" EU-Utility - Widget System (Core Framework Component) Rainmeter-inspired overlay widgets for plugins. Widgets can be positioned, styled, and configured by users. """ import json from pathlib import Path from typing import Dict, List, Optional, Callable, Any from dataclasses import dataclass, asdict from enum import Enum from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QMenu, QInputDialog ) from PyQt6.QtCore import Qt, QPoint, QSize, QTimer, pyqtSignal, QObject from PyQt6.QtGui import QMouseEvent, QPainter, QColor, QFont class WidgetAnchor(Enum): """Widget positioning anchor.""" TOP_LEFT = "top_left" TOP_RIGHT = "top_right" BOTTOM_LEFT = "bottom_left" BOTTOM_RIGHT = "bottom_right" CENTER = "center" FREE = "free" # User positioned @dataclass class WidgetConfig: """Widget configuration.""" x: int = 0 y: int = 0 width: int = 200 height: int = 100 anchor: WidgetAnchor = WidgetAnchor.TOP_RIGHT opacity: float = 1.0 scale: float = 1.0 visible: bool = True locked: bool = False # If True, can't be moved style: Dict[str, Any] = None def __post_init__(self): if self.style is None: self.style = {} class BaseWidget(QFrame): """Base class for all overlay widgets. Similar to Rainmeter skins - draggable, resizable, stylable. """ # Signals moved = pyqtSignal(int, int) # x, y resized = pyqtSignal(int, int) # width, height closed = pyqtSignal() settings_requested = pyqtSignal() def __init__(self, widget_id: str, name: str, config: Optional[WidgetConfig] = None, parent=None): super().__init__(parent) self.widget_id = widget_id self.name = name self.config = config or WidgetConfig() # Window flags for overlay self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | Qt.WindowType.WindowDoesNotAcceptFocus ) # Transparent background self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) # Dragging state self._dragging = False self._drag_offset = QPoint() self._resizing = False self._resize_start = QPoint() self._start_size = QSize() # Setup UI self._setup_base_ui() self._apply_style() self._apply_geometry() # Context menu self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) def _setup_base_ui(self): """Setup the base widget UI.""" # Main layout self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(8, 8, 8, 8) self.main_layout.setSpacing(4) # Header (draggable area) self.header = QFrame() self.header.setFixedHeight(24) self.header.setCursor(Qt.CursorShape.OpenHandCursor) self.header.setStyleSheet(""" QFrame { background-color: rgba(255, 140, 66, 200); border-radius: 4px; } """) header_layout = QHBoxLayout(self.header) header_layout.setContentsMargins(8, 2, 8, 2) header_layout.setSpacing(4) # Title self.title_label = QLabel(self.name) self.title_label.setStyleSheet("color: white; font-weight: bold; font-size: 11px;") header_layout.addWidget(self.title_label) header_layout.addStretch() # Settings button self.settings_btn = QPushButton("⚙") self.settings_btn.setFixedSize(18, 18) self.settings_btn.setStyleSheet(""" QPushButton { background-color: transparent; color: white; border: none; font-size: 10px; } QPushButton:hover { background-color: rgba(255, 255, 255, 50); border-radius: 2px; } """) self.settings_btn.clicked.connect(self.settings_requested.emit) header_layout.addWidget(self.settings_btn) # Close button self.close_btn = QPushButton("×") self.close_btn.setFixedSize(18, 18) self.close_btn.setStyleSheet(""" QPushButton { background-color: transparent; color: white; border: none; font-size: 14px; font-weight: bold; } QPushButton:hover { background-color: rgba(255, 71, 71, 200); border-radius: 2px; } """) self.close_btn.clicked.connect(self.hide) header_layout.addWidget(self.close_btn) self.main_layout.addWidget(self.header) # Content area (subclasses add widgets here) self.content = QFrame() self.content.setStyleSheet(""" QFrame { background-color: rgba(35, 40, 55, 220); border-radius: 4px; } """) self.content_layout = QVBoxLayout(self.content) self.content_layout.setContentsMargins(8, 8, 8, 8) self.main_layout.addWidget(self.content, 1) # Resize handle self.resize_handle = QLabel("⟲") self.resize_handle.setFixedSize(16, 16) self.resize_handle.setAlignment(Qt.AlignmentFlag.AlignCenter) self.resize_handle.setStyleSheet(""" QLabel { color: rgba(255, 255, 255, 150); font-size: 10px; } QLabel:hover { color: white; background-color: rgba(255, 255, 255, 30); border-radius: 2px; } """) self.resize_handle.setCursor(Qt.CursorShape.SizeFDiagCursor) handle_container = QHBoxLayout() handle_container.addStretch() handle_container.addWidget(self.resize_handle) self.main_layout.addLayout(handle_container) def _apply_style(self): """Apply widget styling.""" style = self.config.style or {} # Background color bg_color = style.get('background_color', 'rgba(35, 40, 55, 220)') border_color = style.get('border_color', 'rgba(100, 110, 130, 100)') border_width = style.get('border_width', 1) border_radius = style.get('border_radius', 8) self.setStyleSheet(f""" BaseWidget {{ background-color: {bg_color}; border: {border_width}px solid {border_color}; border-radius: {border_radius}px; }} """) # Opacity self.setWindowOpacity(self.config.opacity) def _apply_geometry(self): """Apply widget geometry.""" self.setGeometry( self.config.x, self.config.y, int(self.config.width * self.config.scale), int(self.config.height * self.config.scale) ) def mousePressEvent(self, event: QMouseEvent): """Handle mouse press for dragging.""" if event.button() == Qt.MouseButton.LeftButton: # Check if clicking resize handle if self.resize_handle.geometry().contains(event.pos()): self._resizing = True self._resize_start = event.globalPosition().toPoint() self._start_size = self.size() event.accept() return # Check if clicking header if self.header.geometry().contains(event.pos()) and not self.config.locked: self._dragging = True self._drag_offset = event.globalPosition().toPoint() - self.frameGeometry().topLeft() self.header.setCursor(Qt.CursorShape.ClosedHandCursor) event.accept() return super().mousePressEvent(event) def mouseMoveEvent(self, event: QMouseEvent): """Handle mouse move for dragging/resizing.""" if self._dragging: new_pos = event.globalPosition().toPoint() - self._drag_offset self.move(new_pos) self.config.x = new_pos.x() self.config.y = new_pos.y() self.moved.emit(self.config.x, self.config.y) event.accept() return if self._resizing: delta = event.globalPosition().toPoint() - self._resize_start new_size = QSize( max(100, self._start_size.width() + delta.x()), max(50, self._start_size.height() + delta.y()) ) self.resize(new_size) self.config.width = int(new_size.width() / self.config.scale) self.config.height = int(new_size.height() / self.config.scale) self.resized.emit(self.config.width, self.config.height) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QMouseEvent): """Handle mouse release.""" if event.button() == Qt.MouseButton.LeftButton: if self._dragging: self._dragging = False self.header.setCursor(Qt.CursorShape.OpenHandCursor) event.accept() return if self._resizing: self._resizing = False event.accept() return super().mouseReleaseEvent(event) def _show_context_menu(self, pos): """Show context menu.""" menu = QMenu(self) menu.setStyleSheet(""" QMenu { background-color: #232837; color: white; border: 1px solid rgba(100, 110, 130, 100); } QMenu::item:selected { background-color: #4a9eff; } """) # Opacity submenu opacity_menu = menu.addMenu("Opacity") for opacity in [0.25, 0.5, 0.75, 1.0]: action = opacity_menu.addAction(f"{int(opacity * 100)}%") action.triggered.connect(lambda checked, o=opacity: self.set_opacity(o)) # Scale submenu scale_menu = menu.addMenu("Scale") for scale in [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]: action = scale_menu.addAction(f"{int(scale * 100)}%") action.triggered.connect(lambda checked, s=scale: self.set_scale(s)) menu.addSeparator() # Lock position lock_action = menu.addAction("🔒 Lock Position" if not self.config.locked else "🔓 Unlock Position") lock_action.triggered.connect(self.toggle_lock) menu.addSeparator() # Settings settings_action = menu.addAction("⚙ Settings...") settings_action.triggered.connect(self.settings_requested.emit) menu.exec(self.mapToGlobal(pos)) def set_opacity(self, opacity: float): """Set widget opacity.""" self.config.opacity = opacity self.setWindowOpacity(opacity) def set_scale(self, scale: float): """Set widget scale.""" self.config.scale = scale self._apply_geometry() def toggle_lock(self): """Toggle position lock.""" self.config.locked = not self.config.locked def save_config(self) -> dict: """Save widget configuration.""" return { 'widget_id': self.widget_id, 'name': self.name, 'config': asdict(self.config) } @classmethod def load_config(cls, data: dict) -> 'BaseWidget': """Load widget from configuration.""" config = WidgetConfig(**data.get('config', {})) return cls(data['widget_id'], data['name'], config) class ClockWidget(BaseWidget): """Example clock widget - demonstrates the system.""" def __init__(self, parent=None): super().__init__("clock", "Clock", parent=parent) self.time_label = QLabel("--:--:--") self.time_label.setStyleSheet(""" QLabel { color: white; font-size: 24px; font-weight: bold; font-family: monospace; } """) self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.content_layout.addWidget(self.time_label) self.date_label = QLabel("----/--/--") self.date_label.setStyleSheet("color: rgba(255,255,255,150); font-size: 12px;") self.date_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.content_layout.addWidget(self.date_label) # Update timer self.timer = QTimer(self) self.timer.timeout.connect(self._update) self.timer.start(1000) # Update every second self._update() def _update(self): """Update clock display.""" from datetime import datetime now = datetime.now() self.time_label.setText(now.strftime("%H:%M:%S")) self.date_label.setText(now.strftime("%Y-%m-%d")) class SystemMonitorWidget(BaseWidget): """Example system monitor widget.""" def __init__(self, parent=None): super().__init__("system", "System Monitor", parent=parent) self.cpu_label = QLabel("CPU: --%") self.cpu_label.setStyleSheet("color: #4ecdc4; font-size: 14px;") self.content_layout.addWidget(self.cpu_label) self.ram_label = QLabel("RAM: --%") self.ram_label.setStyleSheet("color: #ff8c42; font-size: 14px;") self.content_layout.addWidget(self.ram_label) # Update timer self.timer = QTimer(self) self.timer.timeout.connect(self._update) self.timer.start(2000) # Update every 2 seconds self._update() def _update(self): """Update system stats.""" try: import psutil cpu = psutil.cpu_percent() ram = psutil.virtual_memory().percent self.cpu_label.setText(f"CPU: {cpu:.1f}%") self.ram_label.setText(f"RAM: {ram:.1f}%") except ImportError: self.cpu_label.setText("CPU: N/A") self.ram_label.setText("RAM: N/A") class WidgetManager(QObject): """Manager for overlay widgets. Handles widget lifecycle, positioning, and persistence. """ WIDGETS_FILE = Path("config/widgets.json") def __init__(self, parent=None): super().__init__(parent) self.widgets: Dict[str, BaseWidget] = {} self.WIDGETS_FILE.parent.mkdir(parents=True, exist_ok=True) # Load saved widgets self._load_widgets() def register_widget_type(self, widget_id: str, widget_class: type, name: str): """Register a widget type that plugins can create. Args: widget_id: Unique identifier for this widget type widget_class: Class inheriting from BaseWidget name: Human-readable name """ self._widget_types[widget_id] = { 'class': widget_class, 'name': name } def create_widget(self, widget_id: str, instance_id: Optional[str] = None, config: Optional[WidgetConfig] = None) -> BaseWidget: """Create a new widget instance. Args: widget_id: Type of widget to create instance_id: Optional unique instance ID config: Optional configuration Returns: Created widget """ if instance_id is None: instance_id = f"{widget_id}_{len(self.widgets)}" # Create widget (for now, just create built-in types) if widget_id == "clock": widget = ClockWidget() elif widget_id == "system": widget = SystemMonitorWidget() else: # For plugin-created widgets, they'd pass their own class widget = BaseWidget(instance_id, widget_id, config) widget.widget_id = instance_id self.widgets[instance_id] = widget # Connect signals widget.moved.connect(lambda x, y, wid=instance_id: self._on_widget_moved(wid, x, y)) widget.closed.connect(lambda wid=instance_id: self._on_widget_closed(wid)) return widget def show_widget(self, widget_id: str): """Show a widget by ID.""" if widget_id in self.widgets: self.widgets[widget_id].show() def hide_widget(self, widget_id: str): """Hide a widget by ID.""" if widget_id in self.widgets: self.widgets[widget_id].hide() def remove_widget(self, widget_id: str): """Remove and destroy a widget.""" if widget_id in self.widgets: self.widgets[widget_id].close() del self.widgets[widget_id] self._save_widgets() def _on_widget_moved(self, widget_id: str, x: int, y: int): """Handle widget moved.""" self._save_widgets() def _on_widget_closed(self, widget_id: str): """Handle widget closed.""" if widget_id in self.widgets: del self.widgets[widget_id] self._save_widgets() def _save_widgets(self): """Save widget configurations.""" data = { 'widgets': [ widget.save_config() for widget in self.widgets.values() ] } with open(self.WIDGETS_FILE, 'w') as f: json.dump(data, f, indent=2) def _load_widgets(self): """Load widget configurations.""" self._widget_types = {} if not self.WIDGETS_FILE.exists(): return try: with open(self.WIDGETS_FILE, 'r') as f: data = json.load(f) # Recreate widgets (would need widget type registry for full implementation) for widget_data in data.get('widgets', []): # For now, skip loading - plugins would recreate their widgets pass except Exception as e: print(f"[WidgetManager] Failed to load widgets: {e}") def get_all_widgets(self) -> List[BaseWidget]: """Get all managed widgets.""" return list(self.widgets.values()) def show_all(self): """Show all widgets.""" for widget in self.widgets.values(): widget.show() def hide_all(self): """Hide all widgets.""" for widget in self.widgets.values(): widget.hide() # Global widget manager instance _widget_manager: Optional[WidgetManager] = None def get_widget_manager() -> WidgetManager: """Get the global widget manager instance.""" global _widget_manager if _widget_manager is None: _widget_manager = WidgetManager() return _widget_manager