feat: Activity Bar - macOS-style in-game overlay

NEW: core/activity_bar.py
- ActivityBar class for in-game overlay
- Configurable layouts: Horizontal, Vertical, Grid
- Pinned plugins in bar
- Plugin drawer (app drawer style)
- Draggable, resizable
- Opacity and size controls
- Auto-hide feature
- Settings dialog

Features:
- Horizontal/Vertical/Grid layouts
- Adjustable icon size (32-96px)
- Opacity slider (20-100%)
- Auto-hide when not in use
- Plugin drawer with all enabled plugins
- Click plugin to open mini widget or full UI
- Drag to reposition anywhere on screen

INTEGRATION:
- Added to core/main.py
- Auto-created on startup if enabled
- Toggle with Ctrl+Shift+B (configurable)
- Integrated with plugin manager

Usage:
- Install plugins
- They appear in Activity Bar (if pinned) or Drawer
- Click to open mini widget or full UI
- Right-click for settings

This provides a macOS-style dock experience for in-game use,
while keeping the desktop app for configuration.
This commit is contained in:
LemonNexus 2026-02-15 17:54:20 +00:00
parent 7be9e1d763
commit e49fd2a5ba
2 changed files with 620 additions and 0 deletions

580
core/activity_bar.py Normal file
View File

@ -0,0 +1,580 @@
"""
EU-Utility - Activity Bar (In-Game Overlay)
macOS-style activity bar/dock for in-game use.
Features: plugin drawer, mini widgets, floating widgets,
customizable layout (vertical/horizontal/grid), size, opacity.
"""
import json
from pathlib import Path
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, asdict
from enum import Enum
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QScrollArea, QGridLayout, QMenu, QDialog,
QSlider, QComboBox, QCheckBox, QSpinBox, QTabWidget
)
from PyQt6.QtCore import Qt, QPoint, QSize, QTimer, pyqtSignal
from PyQt6.QtGui import QMouseEvent, QPainter, QColor, QIcon, QPixmap
class ActivityBarLayout(Enum):
"""Activity bar layout modes."""
HORIZONTAL = "horizontal"
VERTICAL = "vertical"
GRID = "grid"
@dataclass
class ActivityBarConfig:
"""Activity bar configuration."""
enabled: bool = True
layout: ActivityBarLayout = ActivityBarLayout.HORIZONTAL
position: str = "bottom" # top, bottom, left, right
size: int = 48 # Icon size in pixels
opacity: float = 0.9
always_visible: bool = False
auto_hide: bool = True
show_labels: bool = False
grid_columns: int = 4 # For grid layout
grid_rows: int = 2
pinned_plugins: List[str] = None # Plugin IDs to show in bar
def __post_init__(self):
if self.pinned_plugins is None:
self.pinned_plugins = []
class ActivityBar(QFrame):
"""
macOS-style activity bar for in-game overlay.
Features:
- Plugin drawer (app drawer style)
- Pinned plugins in bar
- Mini widgets
- Configurable layout (horizontal/vertical/grid)
- Draggable, resizable
- Opacity control
"""
widget_requested = pyqtSignal(str) # plugin_id
drawer_toggled = pyqtSignal(bool) # is_open
def __init__(self, plugin_manager, parent=None):
super().__init__(parent)
self.plugin_manager = plugin_manager
self.config = self._load_config()
self.drawer_open = False
self.pinned_buttons: Dict[str, QPushButton] = {}
self.mini_widgets: Dict[str, QWidget] = {}
self._setup_window()
self._setup_ui()
self._apply_config()
# Auto-hide timer
self.hide_timer = QTimer(self)
self.hide_timer.timeout.connect(self._auto_hide)
self.hide_timer.setInterval(3000) # 3 seconds
# Show initially
if self.config.enabled:
self.show()
def _setup_window(self):
"""Setup window properties for overlay."""
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool |
Qt.WindowType.WindowDoesNotAcceptFocus
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
# Make draggable
self._dragging = False
self._drag_offset = QPoint()
def _setup_ui(self):
"""Setup the activity bar UI."""
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(4, 4, 4, 4)
self.main_layout.setSpacing(2)
# Main container
self.container = QFrame()
self.container.setObjectName("activityBarContainer")
self.main_layout.addWidget(self.container)
# Container layout based on config
self._update_container_layout()
# Add pinned plugins
self._refresh_pinned_plugins()
# Drawer button (always last)
self.drawer_btn = self._create_drawer_button()
self._add_to_container(self.drawer_btn)
# Setup drawer panel
self._setup_drawer()
def _update_container_layout(self):
"""Update container layout based on config."""
# Clear existing layout
old_layout = self.container.layout()
if old_layout:
while old_layout.count():
item = old_layout.takeAt(0)
if item.widget():
item.widget().setParent(None)
import sip
sip.delete(old_layout)
# Create new layout
if self.config.layout == ActivityBarLayout.HORIZONTAL:
layout = QHBoxLayout(self.container)
elif self.config.layout == ActivityBarLayout.VERTICAL:
layout = QVBoxLayout(self.container)
else: # GRID
layout = QGridLayout(self.container)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(4)
self.container.setLayout(layout)
# Update container style
self._update_style()
def _update_style(self):
"""Update container styling."""
opacity = int(self.config.opacity * 255)
border_radius = 12 if self.config.layout == ActivityBarLayout.GRID else 20
self.container.setStyleSheet(f"""
QFrame#activityBarContainer {{
background-color: rgba(35, 40, 55, {opacity});
border: 1px solid rgba(100, 110, 130, 100);
border-radius: {border_radius}px;
}}
""")
def _create_drawer_button(self) -> QPushButton:
"""Create the app drawer button."""
btn = QPushButton("⋮⋮⋮") # Drawer icon
btn.setFixedSize(self.config.size, self.config.size)
btn.setStyleSheet(f"""
QPushButton {{
background-color: rgba(255, 140, 66, 200);
color: white;
border: none;
border-radius: {self.config.size // 4}px;
font-size: 16px;
font-weight: bold;
}}
QPushButton:hover {{
background-color: rgba(255, 140, 66, 255);
}}
""")
btn.setToolTip("Plugin Drawer")
btn.clicked.connect(self._toggle_drawer)
return btn
def _create_plugin_button(self, plugin_id: str, plugin_class) -> QPushButton:
"""Create a plugin button for the bar."""
btn = QPushButton()
btn.setFixedSize(self.config.size, self.config.size)
# Get icon
icon_name = getattr(plugin_class, 'icon', 'box')
try:
from core.icon_manager import get_icon_manager
icon_mgr = get_icon_manager()
pixmap = icon_mgr.get_pixmap(icon_name, size=self.config.size - 12)
btn.setIcon(QIcon(pixmap))
btn.setIconSize(QSize(self.config.size - 12, self.config.size - 12))
except:
# Fallback to emoji
btn.setText("📦")
# Style
btn.setStyleSheet(f"""
QPushButton {{
background-color: rgba(60, 65, 80, 180);
border: none;
border-radius: {self.config.size // 4}px;
}}
QPushButton:hover {{
background-color: rgba(80, 85, 100, 220);
}}
""")
# Tooltip
btn.setToolTip(plugin_class.name)
# Click handler
btn.clicked.connect(lambda: self._on_plugin_clicked(plugin_id))
return btn
def _add_to_container(self, widget: QWidget):
"""Add widget to container based on layout."""
layout = self.container.layout()
if isinstance(layout, QGridLayout):
# For grid, calculate position
count = layout.count()
col = count % self.config.grid_columns
row = count // self.config.grid_columns
layout.addWidget(widget, row, col)
else:
layout.addWidget(widget)
def _refresh_pinned_plugins(self):
"""Refresh pinned plugins in the bar."""
# Clear existing pinned buttons (except drawer)
for btn in self.pinned_buttons.values():
btn.deleteLater()
self.pinned_buttons.clear()
if not self.plugin_manager:
return
# Get all enabled plugins
all_plugins = self.plugin_manager.get_all_discovered_plugins()
# Add pinned plugins
for plugin_id in self.config.pinned_plugins:
if plugin_id in all_plugins:
plugin_class = all_plugins[plugin_id]
btn = self._create_plugin_button(plugin_id, plugin_class)
self.pinned_buttons[plugin_id] = btn
self._add_to_container(btn)
def _setup_drawer(self):
"""Setup the plugin drawer panel."""
self.drawer = QFrame(self.parent())
self.drawer.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.WindowStaysOnTopHint |
Qt.WindowType.Tool
)
self.drawer.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.drawer.hide()
# Drawer layout
drawer_layout = QVBoxLayout(self.drawer)
drawer_layout.setContentsMargins(12, 12, 12, 12)
drawer_layout.setSpacing(8)
# Drawer header
header = QLabel("Plugin Drawer")
header.setStyleSheet("""
color: white;
font-size: 16px;
font-weight: bold;
""")
drawer_layout.addWidget(header)
# Scroll area for plugins
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
self.drawer_content = QWidget()
self.drawer_layout = QGridLayout(self.drawer_content)
self.drawer_layout.setSpacing(8)
scroll.setWidget(self.drawer_content)
drawer_layout.addWidget(scroll)
# Refresh drawer content
self._refresh_drawer()
def _refresh_drawer(self):
"""Refresh plugin drawer content."""
# Clear existing
while self.drawer_layout.count():
item = self.drawer_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
if not self.plugin_manager:
return
all_plugins = self.plugin_manager.get_all_discovered_plugins()
col = 0
row = 0
for plugin_id, plugin_class in all_plugins.items():
# Skip already pinned
if plugin_id in self.config.pinned_plugins:
continue
# Create plugin item
item = self._create_drawer_item(plugin_id, plugin_class)
self.drawer_layout.addWidget(item, row, col)
col += 1
if col >= 4: # 4 columns
col = 0
row += 1
def _create_drawer_item(self, plugin_id: str, plugin_class) -> QFrame:
"""Create a drawer item for a plugin."""
frame = QFrame()
frame.setFixedSize(80, 80)
frame.setStyleSheet("""
QFrame {
background-color: rgba(50, 55, 70, 200);
border-radius: 8px;
}
QFrame:hover {
background-color: rgba(70, 75, 90, 220);
}
""")
layout = QVBoxLayout(frame)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(2)
# Icon
icon_label = QLabel("📦")
icon_label.setStyleSheet("font-size: 24px;")
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(icon_label)
# Name
name_label = QLabel(plugin_class.name[:8])
name_label.setStyleSheet("color: white; font-size: 9px;")
name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(name_label)
# Click to open
frame.mousePressEvent = lambda e, pid=plugin_id: self._on_drawer_item_clicked(pid)
return frame
def _toggle_drawer(self):
"""Toggle the plugin drawer."""
self.drawer_open = not self.drawer_open
if self.drawer_open:
# Position drawer near activity bar
pos = self.pos()
if self.config.layout == ActivityBarLayout.HORIZONTAL:
self.drawer.move(pos.x(), pos.y() - 300)
else:
self.drawer.move(pos.x() + self.width(), pos.y())
self.drawer.resize(360, 300)
self.drawer.show()
self.drawer_toggled.emit(True)
else:
self.drawer.hide()
self.drawer_toggled.emit(False)
def _on_plugin_clicked(self, plugin_id: str):
"""Handle plugin button click."""
print(f"[ActivityBar] Plugin clicked: {plugin_id}")
self.widget_requested.emit(plugin_id)
def _on_drawer_item_clicked(self, plugin_id: str):
"""Handle drawer item click."""
print(f"[ActivityBar] Drawer item clicked: {plugin_id}")
self.widget_requested.emit(plugin_id)
self._toggle_drawer() # Close drawer
def _auto_hide(self):
"""Auto-hide the activity bar."""
if self.config.auto_hide and not self.config.always_visible:
# Check if mouse is over bar or drawer
if not self.underMouse() and not self.drawer.underMouse():
self.hide()
def enterEvent(self, event):
"""Mouse entered activity bar."""
if self.config.auto_hide:
self.hide_timer.stop()
super().enterEvent(event)
def leaveEvent(self, event):
"""Mouse left activity bar."""
if self.config.auto_hide and not self.drawer_open:
self.hide_timer.start()
super().leaveEvent(event)
def mousePressEvent(self, event: QMouseEvent):
"""Start dragging."""
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = True
self._drag_offset = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
event.accept()
def mouseMoveEvent(self, event: QMouseEvent):
"""Drag activity bar."""
if self._dragging:
new_pos = event.globalPosition().toPoint() - self._drag_offset
self.move(new_pos)
def mouseReleaseEvent(self, event: QMouseEvent):
"""Stop dragging."""
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = False
self._save_position()
def _apply_config(self):
"""Apply configuration."""
self.setWindowOpacity(self.config.opacity)
self._update_style()
# Resize based on layout and size
if self.config.layout == ActivityBarLayout.HORIZONTAL:
self.resize(400, self.config.size + 20)
elif self.config.layout == ActivityBarLayout.VERTICAL:
self.resize(self.config.size + 20, 400)
else: # GRID
self.resize(
self.config.grid_columns * (self.config.size + 8) + 20,
self.config.grid_rows * (self.config.size + 8) + 20
)
def _load_config(self) -> ActivityBarConfig:
"""Load activity bar configuration."""
config_path = Path("config/activity_bar.json")
if config_path.exists():
try:
data = json.loads(config_path.read_text())
return ActivityBarConfig(**data)
except:
pass
return ActivityBarConfig()
def _save_config(self):
"""Save activity bar configuration."""
config_path = Path("config/activity_bar.json")
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps(asdict(self.config), indent=2))
def _save_position(self):
"""Save bar position."""
# TODO: Save position to config
pass
def show_settings_dialog(self):
"""Show activity bar settings dialog."""
dialog = ActivityBarSettingsDialog(self.config, self)
if dialog.exec():
self.config = dialog.get_config()
self._apply_config()
self._save_config()
self._refresh_pinned_plugins()
class ActivityBarSettingsDialog(QDialog):
"""Settings dialog for activity bar."""
def __init__(self, config: ActivityBarConfig, parent=None):
super().__init__(parent)
self.config = config
self.setWindowTitle("Activity Bar Settings")
self.setMinimumSize(400, 500)
self._setup_ui()
def _setup_ui(self):
"""Setup settings UI."""
from PyQt6.QtWidgets import QVBoxLayout, QFormLayout, QDialogButtonBox
layout = QVBoxLayout(self)
# Form
form = QFormLayout()
# Enabled
self.enabled_cb = QCheckBox("Enable Activity Bar")
self.enabled_cb.setChecked(self.config.enabled)
form.addRow(self.enabled_cb)
# Layout
self.layout_combo = QComboBox()
self.layout_combo.addItems(["Horizontal", "Vertical", "Grid"])
self.layout_combo.setCurrentText(self.config.layout.value.title())
form.addRow("Layout:", self.layout_combo)
# Size
self.size_spin = QSpinBox()
self.size_spin.setRange(32, 96)
self.size_spin.setValue(self.config.size)
form.addRow("Icon Size:", self.size_spin)
# Opacity
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
self.opacity_slider.setRange(20, 100)
self.opacity_slider.setValue(int(self.config.opacity * 100))
form.addRow("Opacity:", self.opacity_slider)
# Auto-hide
self.autohide_cb = QCheckBox("Auto-hide when not in use")
self.autohide_cb.setChecked(self.config.auto_hide)
form.addRow(self.autohide_cb)
# Show labels
self.labels_cb = QCheckBox("Show plugin labels")
self.labels_cb.setChecked(self.config.show_labels)
form.addRow(self.labels_cb)
# Grid config
self.grid_cols = QSpinBox()
self.grid_cols.setRange(2, 8)
self.grid_cols.setValue(self.config.grid_columns)
form.addRow("Grid Columns:", self.grid_cols)
layout.addLayout(form)
# Buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def get_config(self) -> ActivityBarConfig:
"""Get updated configuration."""
layout_map = {
"Horizontal": ActivityBarLayout.HORIZONTAL,
"Vertical": ActivityBarLayout.VERTICAL,
"Grid": ActivityBarLayout.GRID
}
return ActivityBarConfig(
enabled=self.enabled_cb.isChecked(),
layout=layout_map.get(self.layout_combo.currentText(), ActivityBarLayout.HORIZONTAL),
size=self.size_spin.value(),
opacity=self.opacity_slider.value() / 100,
auto_hide=self.autohide_cb.isChecked(),
show_labels=self.labels_cb.isChecked(),
grid_columns=self.grid_cols.value(),
pinned_plugins=self.config.pinned_plugins
)
# Global instance
_activity_bar: Optional[ActivityBar] = None
def get_activity_bar(plugin_manager=None) -> Optional[ActivityBar]:
"""Get or create global activity bar instance."""
global _activity_bar
if _activity_bar is None and plugin_manager:
_activity_bar = ActivityBar(plugin_manager)
return _activity_bar

View File

@ -122,6 +122,17 @@ class EUUtilityApp:
self.floating_icon.clicked.connect(self._toggle_overlay) self.floating_icon.clicked.connect(self._toggle_overlay)
self.floating_icon.show() self.floating_icon.show()
# Create Activity Bar (in-game overlay)
print("Creating Activity Bar...")
from core.activity_bar import get_activity_bar
self.activity_bar = get_activity_bar(self.plugin_manager)
if self.activity_bar and self.activity_bar.config.enabled:
print("[Core] Activity Bar enabled")
# Connect signals
self.activity_bar.widget_requested.connect(self._on_activity_bar_widget)
else:
print("[Core] Activity Bar disabled")
# Connect hotkey signals # Connect hotkey signals
self.hotkey_handler.toggle_signal.connect(self._on_toggle_signal) self.hotkey_handler.toggle_signal.connect(self._on_toggle_signal)
@ -301,6 +312,35 @@ class EUUtilityApp:
"""Handle toggle signal in main thread.""" """Handle toggle signal in main thread."""
self._toggle_overlay() self._toggle_overlay()
def _on_activity_bar_widget(self, plugin_id: str):
"""Handle activity bar widget request."""
print(f"[Main] Activity bar requested plugin: {plugin_id}")
# Get plugin class
all_plugins = self.plugin_manager.get_all_discovered_plugins()
if plugin_id not in all_plugins:
print(f"[Main] Plugin not found: {plugin_id}")
return
plugin_class = all_plugins[plugin_id]
# Check if plugin has a mini widget
if hasattr(plugin_class, 'get_mini_widget'):
try:
# Create mini widget
widget = plugin_class.get_mini_widget()
if widget:
widget.show()
print(f"[Main] Created mini widget for {plugin_class.name}")
except Exception as e:
print(f"[Main] Error creating mini widget: {e}")
else:
# No mini widget, try to show main overlay
self._toggle_overlay()
# Switch to this plugin
if self.overlay:
self.overlay.show_plugin(plugin_id)
def _toggle_overlay(self): def _toggle_overlay(self):
"""Toggle overlay visibility.""" """Toggle overlay visibility."""
if self.overlay: if self.overlay: