""" 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)