EU-Utility/core/widget_system.py

578 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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