EU-Utility/core/widget_system.py

364 lines
12 KiB
Python
Raw Permalink 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)