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