EU-Utility/core/api/widget_api.py

907 lines
27 KiB
Python

"""
EU-Utility - Widget API
================
The WidgetAPI provides a comprehensive interface for creating, managing,
and interacting with overlay widgets.
Widgets are floating, draggable UI components that appear over the game.
They can display real-time data, controls, or mini-versions of plugins.
Quick Start:
-----------
```python
from core.api.widget_api import get_widget_api
class MyPlugin(BasePlugin):
def initialize(self):
self.widget_api = get_widget_api()
def create_widget(self):
widget = self.widget_api.create_widget(
name="my_widget",
title="My Widget",
size=(300, 200)
)
widget.show()
```
Widget Types:
------------
- MiniWidget - Small info display
- ControlWidget - Interactive controls
- ChartWidget - Data visualization
- AlertWidget - Notifications/overlays
For full documentation, see: docs/WIDGET_API.md
"""
import json
from pathlib import Path
from typing import Optional, Dict, List, Callable, Any, Tuple, Union
from dataclasses import dataclass, asdict
from enum import Enum
from datetime import datetime
from core.logger import get_logger
logger = get_logger(__name__)
class WidgetType(Enum):
"""Types of overlay widgets."""
MINI = "mini" # Small info display
CONTROL = "control" # Interactive controls
CHART = "chart" # Data visualization
ALERT = "alert" # Notification overlay
CUSTOM = "custom" # Custom widget
class WidgetAnchor(Enum):
"""Widget anchor positions."""
TOP_LEFT = "top_left"
TOP_CENTER = "top_center"
TOP_RIGHT = "top_right"
CENTER_LEFT = "center_left"
CENTER = "center"
CENTER_RIGHT = "center_right"
BOTTOM_LEFT = "bottom_left"
BOTTOM_CENTER = "bottom_center"
BOTTOM_RIGHT = "bottom_right"
@dataclass
class WidgetConfig:
"""Configuration for a widget."""
name: str
title: str
widget_type: WidgetType = WidgetType.MINI
size: Tuple[int, int] = (300, 200)
position: Tuple[int, int] = (100, 100)
anchor: WidgetAnchor = WidgetAnchor.TOP_LEFT
opacity: float = 0.95
always_on_top: bool = True
locked: bool = False # If True, cannot be moved
resizable: bool = True
minimizable: bool = True
closable: bool = True
show_in_taskbar: bool = False
snap_to_grid: bool = False
grid_size: int = 10
def to_dict(self) -> Dict:
"""Convert to dictionary."""
return {
'name': self.name,
'title': self.title,
'widget_type': self.widget_type.value,
'size': self.size,
'position': self.position,
'anchor': self.anchor.value,
'opacity': self.opacity,
'always_on_top': self.always_on_top,
'locked': self.locked,
'resizable': self.resizable,
'minimizable': self.minimizable,
'closable': self.closable,
'show_in_taskbar': self.show_in_taskbar,
'snap_to_grid': self.snap_to_grid,
'grid_size': self.grid_size
}
@classmethod
def from_dict(cls, data: Dict) -> 'WidgetConfig':
"""Create from dictionary."""
return cls(
name=data.get('name', 'unnamed'),
title=data.get('title', 'Widget'),
widget_type=WidgetType(data.get('widget_type', 'mini')),
size=tuple(data.get('size', [300, 200])),
position=tuple(data.get('position', [100, 100])),
anchor=WidgetAnchor(data.get('anchor', 'top_left')),
opacity=data.get('opacity', 0.95),
always_on_top=data.get('always_on_top', True),
locked=data.get('locked', False),
resizable=data.get('resizable', True),
minimizable=data.get('minimizable', True),
closable=data.get('closable', True),
show_in_taskbar=data.get('show_in_taskbar', False),
snap_to_grid=data.get('snap_to_grid', False),
grid_size=data.get('grid_size', 10)
)
class Widget:
"""
Widget instance - a floating overlay window.
This is a wrapper around the actual QWidget that provides
a clean API for plugin developers.
Example:
--------
```python
widget = widget_api.create_widget(
name="loot_tracker",
title="Loot Tracker",
size=(400, 300)
)
# Set content
widget.set_content(my_widget_content)
# Show widget
widget.show()
# Update position
widget.move(500, 200)
```
"""
def __init__(self, config: WidgetConfig, qt_widget=None):
self.config = config
self._qt_widget = qt_widget
self._content = None
self._callbacks: Dict[str, List[Callable]] = {}
self._created_at = datetime.now()
self._last_moved = None
@property
def name(self) -> str:
"""Widget name (unique identifier)."""
return self.config.name
@property
def title(self) -> str:
"""Widget title (displayed in header)."""
return self.config.title
@property
def visible(self) -> bool:
"""Whether widget is currently visible."""
if self._qt_widget:
return self._qt_widget.isVisible()
return False
@property
def position(self) -> Tuple[int, int]:
"""Current widget position (x, y)."""
if self._qt_widget:
pos = self._qt_widget.pos()
return (pos.x(), pos.y())
return self.config.position
@property
def size(self) -> Tuple[int, int]:
"""Current widget size (width, height)."""
if self._qt_widget:
sz = self._qt_widget.size()
return (sz.width(), sz.height())
return self.config.size
# =====================================================================
# Widget Operations
# =====================================================================
def show(self) -> None:
"""Show the widget."""
if self._qt_widget:
self._qt_widget.show()
logger.debug(f"[Widget] Showed: {self.name}")
def hide(self) -> None:
"""Hide the widget."""
if self._qt_widget:
self._qt_widget.hide()
logger.debug(f"[Widget] Hid: {self.name}")
def close(self) -> bool:
"""
Close and destroy the widget.
Returns:
True if closed successfully
"""
if self._qt_widget:
self._trigger_callback('closing')
self._qt_widget.close()
self._qt_widget.deleteLater()
self._qt_widget = None
self._trigger_callback('closed')
logger.debug(f"[Widget] Closed: {self.name}")
return True
return False
def move(self, x: int, y: int) -> None:
"""
Move widget to position.
Args:
x: X coordinate
y: Y coordinate
"""
if self._qt_widget:
# Apply grid snapping if enabled
if self.config.snap_to_grid:
x = round(x / self.config.grid_size) * self.config.grid_size
y = round(y / self.config.grid_size) * self.config.grid_size
self._qt_widget.move(x, y)
self.config.position = (x, y)
self._last_moved = datetime.now()
self._trigger_callback('moved', {'x': x, 'y': y})
def resize(self, width: int, height: int) -> None:
"""
Resize widget.
Args:
width: New width
height: New height
"""
if self._qt_widget:
self._qt_widget.resize(width, height)
self.config.size = (width, height)
self._trigger_callback('resized', {'width': width, 'height': height})
def set_opacity(self, opacity: float) -> None:
"""
Set widget opacity.
Args:
opacity: 0.0 to 1.0
"""
opacity = max(0.0, min(1.0, opacity))
self.config.opacity = opacity
if self._qt_widget:
self._qt_widget.setWindowOpacity(opacity)
def set_title(self, title: str) -> None:
"""Update widget title."""
self.config.title = title
if self._qt_widget:
self._qt_widget.setWindowTitle(title)
def set_locked(self, locked: bool) -> None:
"""
Lock/unlock widget position.
When locked, the widget cannot be moved by dragging.
"""
self.config.locked = locked
if self._qt_widget:
# Update internal flag
setattr(self._qt_widget, '_locked', locked)
def minimize(self) -> None:
"""Minimize widget."""
if self._qt_widget and self.config.minimizable:
self._qt_widget.showMinimized()
def maximize(self) -> None:
"""Maximize widget."""
if self._qt_widget:
self._qt_widget.showMaximized()
def restore(self) -> None:
"""Restore from minimized/maximized state."""
if self._qt_widget:
self._qt_widget.showNormal()
def raise_widget(self) -> None:
"""Bring widget to front."""
if self._qt_widget:
self._qt_widget.raise_()
self._qt_widget.activateWindow()
def lower_widget(self) -> None:
"""Send widget to back."""
if self._qt_widget:
self._qt_widget.lower()
# =====================================================================
# Content Management
# =====================================================================
def set_content(self, widget) -> None:
"""
Set the main content widget.
Args:
widget: QWidget to display inside this widget
"""
self._content = widget
if self._qt_widget:
# Assuming the qt_widget has a content area
if hasattr(self._qt_widget, 'set_content'):
self._qt_widget.set_content(widget)
def get_content(self) -> Optional[Any]:
"""Get the current content widget."""
return self._content
def update_content(self, data: Any) -> None:
"""
Update widget content with new data.
This triggers the 'update' callback.
Args:
data: New data to display
"""
self._trigger_callback('update', data)
def flash(self, duration_ms: int = 1000, color: str = "#ff8c42") -> None:
"""
Flash widget to draw attention.
Args:
duration_ms: Flash duration
color: Flash color (hex)
"""
if self._qt_widget and hasattr(self._qt_widget, 'flash'):
self._qt_widget.flash(duration_ms, color)
# =====================================================================
# Event Handling
# =====================================================================
def on(self, event: str, callback: Callable) -> None:
"""
Register event callback.
Events:
- 'moved': Widget was moved (data: {'x': int, 'y': int})
- 'resized': Widget was resized (data: {'width': int, 'height': int})
- 'closing': Widget is about to close
- 'closed': Widget was closed
- 'update': Content should update (data: new data)
- 'focus': Widget gained focus
- 'blur': Widget lost focus
Args:
event: Event name
callback: Function to call
"""
if event not in self._callbacks:
self._callbacks[event] = []
self._callbacks[event].append(callback)
def off(self, event: str, callback: Callable = None) -> None:
"""
Unregister event callback.
Args:
event: Event name
callback: Specific callback to remove (if None, removes all)
"""
if event in self._callbacks:
if callback:
self._callbacks[event] = [
cb for cb in self._callbacks[event] if cb != callback
]
else:
del self._callbacks[event]
def _trigger_callback(self, event: str, data: Any = None) -> None:
"""Internal: Trigger event callbacks."""
if event in self._callbacks:
for callback in self._callbacks[event]:
try:
if data is not None:
callback(data)
else:
callback()
except Exception as e:
logger.error(f"[Widget] Callback error for {event}: {e}")
# =====================================================================
# Persistence
# =====================================================================
def save_state(self) -> Dict:
"""
Save widget state to dictionary.
Returns:
State dictionary for persistence
"""
return {
'config': self.config.to_dict(),
'position': self.position,
'size': self.size,
'visible': self.visible,
'created_at': self._created_at.isoformat(),
'last_moved': self._last_moved.isoformat() if self._last_moved else None
}
def load_state(self, state: Dict) -> None:
"""
Restore widget state from dictionary.
Args:
state: State dictionary from save_state()
"""
if 'config' in state:
self.config = WidgetConfig.from_dict(state['config'])
if 'position' in state:
x, y = state['position']
self.move(x, y)
if 'size' in state:
width, height = state['size']
self.resize(width, height)
if state.get('visible'):
self.show()
class WidgetAPI:
"""
WidgetAPI - API for widget management.
Provides methods to create, manage, and interact with overlay widgets.
Example:
--------
```python
from core.api.widget_api import get_widget_api
api = get_widget_api()
# Create a widget
widget = api.create_widget(
name="loot_counter",
title="Loot Counter",
size=(250, 150)
)
# Configure it
widget.set_opacity(0.9)
widget.move(100, 100)
# Show it
widget.show()
# Later...
api.hide_all_widgets() # Hide all
api.show_widget("loot_counter") # Show specific one
```
"""
_instance: Optional['WidgetAPI'] = None
_widgets: Dict[str, Widget] = {}
_widget_factory: Optional[Callable] = None
_presets: Dict[str, WidgetConfig] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# =====================================================================
# Widget Creation
# =====================================================================
def create_widget(self, name: str, title: str = None,
size: Tuple[int, int] = (300, 200),
position: Tuple[int, int] = (100, 100),
widget_type: WidgetType = WidgetType.MINI,
**kwargs) -> Widget:
"""
Create a new overlay widget.
Args:
name: Unique widget identifier
title: Display title (default: same as name)
size: (width, height) tuple
position: (x, y) tuple
widget_type: Type of widget
**kwargs: Additional config options
Returns:
Widget instance
Raises:
ValueError: If widget name already exists
Example:
>>> widget = api.create_widget(
... name="my_tracker",
... title="My Tracker",
... size=(400, 300),
... opacity=0.9
... )
"""
if name in self._widgets:
raise ValueError(f"Widget '{name}' already exists")
config = WidgetConfig(
name=name,
title=title or name.replace('_', ' ').title(),
widget_type=widget_type,
size=size,
position=position,
**{k: v for k, v in kwargs.items()
if k in WidgetConfig.__dataclass_fields__}
)
# Create actual Qt widget via factory
qt_widget = None
if self._widget_factory:
qt_widget = self._widget_factory(config)
widget = Widget(config, qt_widget)
self._widgets[name] = widget
logger.info(f"[WidgetAPI] Created widget: {name}")
return widget
def create_from_preset(self, preset_name: str,
name: str = None) -> Optional[Widget]:
"""
Create widget from a preset configuration.
Args:
preset_name: Name of preset to use
name: Override widget name (optional)
Returns:
Widget instance or None if preset not found
Example:
>>> widget = api.create_from_preset("loot_tracker", "my_loot")
"""
if preset_name not in self._presets:
logger.error(f"[WidgetAPI] Preset not found: {preset_name}")
return None
preset = self._presets[preset_name]
widget_name = name or f"{preset_name}_{len(self._widgets)}"
config = WidgetConfig(
name=widget_name,
title=preset.title,
widget_type=preset.widget_type,
size=preset.size,
position=preset.position,
**{k: getattr(preset, k) for k in ['opacity', 'always_on_top',
'locked', 'resizable', 'minimizable', 'closable']}
)
qt_widget = None
if self._widget_factory:
qt_widget = self._widget_factory(config)
widget = Widget(config, qt_widget)
self._widgets[widget_name] = widget
logger.info(f"[WidgetAPI] Created widget from preset: {preset_name}")
return widget
def register_preset(self, name: str, config: WidgetConfig) -> None:
"""
Register a widget preset for reuse.
Args:
name: Preset name
config: WidgetConfig to use as template
"""
self._presets[name] = config
logger.debug(f"[WidgetAPI] Registered preset: {name}")
# =====================================================================
# Widget Access
# =====================================================================
def get_widget(self, name: str) -> Optional[Widget]:
"""
Get widget by name.
Args:
name: Widget name
Returns:
Widget instance or None if not found
"""
return self._widgets.get(name)
def get_all_widgets(self) -> List[Widget]:
"""Get all widgets."""
return list(self._widgets.values())
def get_visible_widgets(self) -> List[Widget]:
"""Get all visible widgets."""
return [w for w in self._widgets.values() if w.visible]
def widget_exists(self, name: str) -> bool:
"""Check if widget exists."""
return name in self._widgets
# =====================================================================
# Widget Management
# =====================================================================
def show_widget(self, name: str) -> bool:
"""
Show a specific widget.
Args:
name: Widget name
Returns:
True if successful
"""
widget = self._widgets.get(name)
if widget:
widget.show()
return True
return False
def hide_widget(self, name: str) -> bool:
"""
Hide a specific widget.
Args:
name: Widget name
Returns:
True if successful
"""
widget = self._widgets.get(name)
if widget:
widget.hide()
return True
return False
def close_widget(self, name: str) -> bool:
"""
Close and destroy a widget.
Args:
name: Widget name
Returns:
True if closed
"""
widget = self._widgets.get(name)
if widget:
widget.close()
del self._widgets[name]
return True
return False
def show_all_widgets(self) -> None:
"""Show all widgets."""
for widget in self._widgets.values():
widget.show()
def hide_all_widgets(self) -> None:
"""Hide all widgets."""
for widget in self._widgets.values():
widget.hide()
def close_all_widgets(self) -> None:
"""Close all widgets."""
for widget in list(self._widgets.values()):
widget.close()
self._widgets.clear()
def minimize_all(self) -> None:
"""Minimize all widgets."""
for widget in self._widgets.values():
widget.minimize()
def restore_all(self) -> None:
"""Restore all minimized widgets."""
for widget in self._widgets.values():
widget.restore()
def set_all_opacity(self, opacity: float) -> None:
"""
Set opacity for all widgets.
Args:
opacity: 0.0 to 1.0
"""
for widget in self._widgets.values():
widget.set_opacity(opacity)
def lock_all(self) -> None:
"""Lock all widgets (prevent moving)."""
for widget in self._widgets.values():
widget.set_locked(True)
def unlock_all(self) -> None:
"""Unlock all widgets."""
for widget in self._widgets.values():
widget.set_locked(False)
# =====================================================================
# Layout Helpers
# =====================================================================
def arrange_widgets(self, layout: str = "grid",
spacing: int = 10) -> None:
"""
Automatically arrange visible widgets.
Args:
layout: 'grid', 'horizontal', 'vertical', 'cascade'
spacing: Space between widgets
"""
visible = self.get_visible_widgets()
if not visible:
return
if layout == "grid":
self._arrange_grid(visible, spacing)
elif layout == "horizontal":
self._arrange_horizontal(visible, spacing)
elif layout == "vertical":
self._arrange_vertical(visible, spacing)
elif layout == "cascade":
self._arrange_cascade(visible, spacing)
def _arrange_grid(self, widgets: List[Widget], spacing: int) -> None:
"""Arrange widgets in a grid."""
import math
cols = math.ceil(math.sqrt(len(widgets)))
x, y = 100, 100
for i, widget in enumerate(widgets):
col = i % cols
row = i // cols
widget_x = x + col * (300 + spacing)
widget_y = y + row * (200 + spacing)
widget.move(widget_x, widget_y)
def _arrange_horizontal(self, widgets: List[Widget], spacing: int) -> None:
"""Arrange widgets horizontally."""
x, y = 100, 100
for widget in widgets:
widget.move(x, y)
x += widget.size[0] + spacing
def _arrange_vertical(self, widgets: List[Widget], spacing: int) -> None:
"""Arrange widgets vertically."""
x, y = 100, 100
for widget in widgets:
widget.move(x, y)
y += widget.size[1] + spacing
def _arrange_cascade(self, widgets: List[Widget], spacing: int) -> None:
"""Arrange widgets in cascade."""
x, y = 100, 100
for widget in widgets:
widget.move(x, y)
x += spacing
y += spacing
def snap_to_grid(self, grid_size: int = 10) -> None:
"""
Snap all widgets to grid.
Args:
grid_size: Grid size in pixels
"""
for widget in self._widgets.values():
x, y = widget.position
x = round(x / grid_size) * grid_size
y = round(y / grid_size) * grid_size
widget.move(x, y)
# =====================================================================
# Persistence
# =====================================================================
def save_all_states(self, filepath: str = None) -> Dict:
"""
Save all widget states.
Args:
filepath: Optional file to save to
Returns:
State dictionary
"""
states = {
name: widget.save_state()
for name, widget in self._widgets.items()
}
if filepath:
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w') as f:
json.dump(states, f, indent=2)
return states
def load_all_states(self, data: Union[str, Dict]) -> None:
"""
Load widget states.
Args:
data: File path or state dictionary
"""
if isinstance(data, str):
with open(data, 'r') as f:
states = json.load(f)
else:
states = data
for name, state in states.items():
if name in self._widgets:
self._widgets[name].load_state(state)
# =====================================================================
# Internal
# =====================================================================
def _set_widget_factory(self, factory: Callable) -> None:
"""
Set the factory function for creating Qt widgets.
Internal use only - called by core system.
Args:
factory: Function that takes WidgetConfig and returns QWidget
"""
self._widget_factory = factory
# Global instance
_widget_api_instance: Optional[WidgetAPI] = None
def get_widget_api() -> WidgetAPI:
"""
Get the global WidgetAPI instance.
Returns:
WidgetAPI singleton
Example:
>>> from core.api.widget_api import get_widget_api
>>> api = get_widget_api()
>>> widget = api.create_widget("my_widget", "My Widget")
"""
global _widget_api_instance
if _widget_api_instance is None:
_widget_api_instance = WidgetAPI()
return _widget_api_instance
# Convenience exports
__all__ = [
'WidgetAPI',
'get_widget_api',
'Widget',
'WidgetConfig',
'WidgetType',
'WidgetAnchor'
]