feat: Rainmeter-style Widget System - draggable, resizable overlay widgets for plugins
This commit is contained in:
parent
fe7b4c9d94
commit
b9611d6965
|
|
@ -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
|
||||
Loading…
Reference in New Issue