""" 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