diff --git a/core/widget_system.py b/core/widget_system.py new file mode 100644 index 0000000..2c5b1fb --- /dev/null +++ b/core/widget_system.py @@ -0,0 +1,577 @@ +""" +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