diff --git a/core/activity_bar.py b/core/activity_bar.py new file mode 100644 index 0000000..0624ae0 --- /dev/null +++ b/core/activity_bar.py @@ -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 diff --git a/core/main.py b/core/main.py index ba51e19..5ad56a6 100644 --- a/core/main.py +++ b/core/main.py @@ -122,6 +122,17 @@ class EUUtilityApp: self.floating_icon.clicked.connect(self._toggle_overlay) 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 self.hotkey_handler.toggle_signal.connect(self._on_toggle_signal) @@ -301,6 +312,35 @@ class EUUtilityApp: """Handle toggle signal in main thread.""" 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): """Toggle overlay visibility.""" if self.overlay: