""" EU-Utility - Windows Taskbar Style Activity Bar =============================================== Windows 11-style taskbar for in-game overlay. Features: Transparent background, search box, pinned plugins, clean minimal design that expands as needed. """ import json from pathlib import Path from typing import Dict, List, Optional, Callable from dataclasses import dataclass, asdict from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QLineEdit, QMenu, QDialog, QSlider, QComboBox, QCheckBox, QSpinBox, QApplication, QSizePolicy ) from PyQt6.QtCore import Qt, QPoint, QSize, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve from PyQt6.QtGui import QMouseEvent, QPainter, QColor, QFont, QIcon, QPixmap @dataclass class ActivityBarConfig: """Activity bar configuration.""" enabled: bool = True position: str = "bottom" # top, bottom icon_size: int = 32 auto_hide: bool = True auto_hide_delay: int = 3000 # milliseconds pinned_plugins: List[str] = None def __post_init__(self): if self.pinned_plugins is None: self.pinned_plugins = [] def to_dict(self): return { 'enabled': self.enabled, 'position': self.position, 'icon_size': self.icon_size, 'auto_hide': self.auto_hide, 'auto_hide_delay': self.auto_hide_delay, 'pinned_plugins': self.pinned_plugins } @classmethod def from_dict(cls, data): return cls( enabled=data.get('enabled', True), position=data.get('position', 'bottom'), icon_size=data.get('icon_size', 32), auto_hide=data.get('auto_hide', True), auto_hide_delay=data.get('auto_hide_delay', 3000), pinned_plugins=data.get('pinned_plugins', []) ) class WindowsTaskbar(QFrame): """ Windows 11-style taskbar for in-game overlay. Features: - Transparent background (no background visible) - Windows-style start button - Search box for quick access - Pinned plugins expand the bar - Clean, minimal design """ widget_requested = pyqtSignal(str) search_requested = pyqtSignal(str) def __init__(self, plugin_manager, parent=None): super().__init__(parent) self.plugin_manager = plugin_manager self.config = self._load_config() # State self.pinned_buttons: Dict[str, QPushButton] = {} self.is_expanded = False self.search_text = "" # Auto-hide timer (must be created before _apply_config) self.hide_timer = QTimer(self) self.hide_timer.timeout.connect(self._auto_hide) self._setup_window() self._setup_ui() self._apply_config() # Show if enabled if self.config.enabled: self.show() def _setup_window(self): """Setup frameless overlay window.""" self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | Qt.WindowType.WindowDoesNotAcceptFocus ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) # Draggable state self._dragging = False self._drag_offset = QPoint() def _setup_ui(self): """Setup Windows taskbar-style UI.""" # Main layout with minimal margins layout = QHBoxLayout(self) layout.setContentsMargins(8, 4, 8, 4) layout.setSpacing(4) # Style: No background, just floating elements self.setStyleSheet(""" WindowsTaskbar { background: transparent; border: none; } """) # === START BUTTON (Windows icon style) === self.start_btn = QPushButton("⊞") # Windows-like icon self.start_btn.setFixedSize(40, 40) self.start_btn.setStyleSheet(""" QPushButton { background: rgba(255, 255, 255, 0.1); color: white; border: none; border-radius: 8px; font-size: 20px; font-weight: bold; } QPushButton:hover { background: rgba(255, 255, 255, 0.2); } QPushButton:pressed { background: rgba(255, 255, 255, 0.15); } """) self.start_btn.setToolTip("Open App Drawer") self.start_btn.clicked.connect(self._toggle_drawer) layout.addWidget(self.start_btn) # === SEARCH BOX (Windows 11 style) === self.search_box = QLineEdit() self.search_box.setFixedSize(200, 36) self.search_box.setPlaceholderText("Search plugins...") self.search_box.setStyleSheet(""" QLineEdit { background: rgba(255, 255, 255, 0.08); color: white; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 18px; padding: 0 16px; font-size: 13px; } QLineEdit:hover { background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.15); } QLineEdit:focus { background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 140, 66, 0.5); } QLineEdit::placeholder { color: rgba(255, 255, 255, 0.4); } """) self.search_box.returnPressed.connect(self._on_search) self.search_box.textChanged.connect(self._on_search_text_changed) layout.addWidget(self.search_box) # Separator separator = QFrame() separator.setFixedSize(1, 24) separator.setStyleSheet("background: rgba(255, 255, 255, 0.1);") layout.addWidget(separator) # === PINNED PLUGINS AREA (expandable) === self.pinned_container = QWidget() self.pinned_layout = QHBoxLayout(self.pinned_container) self.pinned_layout.setContentsMargins(0, 0, 0, 0) self.pinned_layout.setSpacing(4) layout.addWidget(self.pinned_container) # Spacer to push icons together layout.addStretch() # === SYSTEM TRAY AREA === # Add a small clock or status indicator self.clock_label = QLabel("12:00") self.clock_label.setStyleSheet(""" color: rgba(255, 255, 255, 0.7); font-size: 12px; padding: 0 8px; """) self.clock_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.clock_label) # Start clock update timer self.clock_timer = QTimer(self) self.clock_timer.timeout.connect(self._update_clock) self.clock_timer.start(60000) # Update every minute self._update_clock() # Setup context menu self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) # Refresh pinned plugins self._refresh_pinned_plugins() def _create_plugin_button(self, plugin_id: str, plugin_class) -> QPushButton: """Create a pinned plugin button (taskbar icon style).""" btn = QPushButton() size = self.config.icon_size btn.setFixedSize(size + 8, size + 8) # Get plugin icon or use default icon_text = getattr(plugin_class, 'icon', '◆') btn.setText(icon_text) btn.setStyleSheet(f""" QPushButton {{ background: transparent; color: white; border: none; border-radius: 6px; font-size: {size // 2}px; }} QPushButton:hover {{ background: rgba(255, 255, 255, 0.1); }} QPushButton:pressed {{ background: rgba(255, 255, 255, 0.05); }} """) btn.setToolTip(plugin_class.name) btn.clicked.connect(lambda: self._on_plugin_clicked(plugin_id)) return btn def _refresh_pinned_plugins(self): """Refresh pinned plugin buttons.""" # Clear existing 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.pinned_layout.addWidget(btn) def _toggle_drawer(self): """Toggle the app drawer (like Windows Start menu).""" # Create drawer if not exists if not hasattr(self, 'drawer') or self.drawer is None: self._create_drawer() if self.drawer.isVisible(): self.drawer.hide() else: # Position above the taskbar bar_pos = self.pos() self.drawer.move(bar_pos.x(), bar_pos.y() - self.drawer.height()) self.drawer.show() self.drawer.raise_() def _create_drawer(self): """Create the app drawer popup.""" 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.setFixedSize(400, 500) # Drawer style: subtle frosted glass self.drawer.setStyleSheet(""" QFrame { background: rgba(32, 32, 32, 0.95); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; } """) layout = QVBoxLayout(self.drawer) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(12) # Header header = QLabel("All Plugins") header.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") layout.addWidget(header) # Search in drawer drawer_search = QLineEdit() drawer_search.setPlaceholderText("Search...") drawer_search.setStyleSheet(""" QLineEdit { background: rgba(255, 255, 255, 0.08); color: white; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 8px 12px; } """) layout.addWidget(drawer_search) # Plugin grid plugins_widget = QWidget() plugins_layout = QVBoxLayout(plugins_widget) plugins_layout.setSpacing(8) if self.plugin_manager: all_plugins = self.plugin_manager.get_all_discovered_plugins() for plugin_id, plugin_class in all_plugins.items(): item = self._create_drawer_item(plugin_id, plugin_class) plugins_layout.addWidget(item) plugins_layout.addStretch() layout.addWidget(plugins_widget) def _create_drawer_item(self, plugin_id: str, plugin_class) -> QPushButton: """Create a drawer item (like Start menu app).""" btn = QPushButton(f" {getattr(plugin_class, 'icon', '◆')} {plugin_class.name}") btn.setFixedHeight(44) btn.setStyleSheet(""" QPushButton { background: transparent; color: white; border: none; border-radius: 8px; text-align: left; font-size: 13px; } QPushButton:hover { background: rgba(255, 255, 255, 0.1); } """) btn.clicked.connect(lambda: self._on_drawer_item_clicked(plugin_id)) return btn def _on_plugin_clicked(self, plugin_id: str): """Handle pinned plugin click.""" print(f"[Taskbar] Plugin clicked: {plugin_id}") self.widget_requested.emit(plugin_id) self._pulse_animation() def _on_drawer_item_clicked(self, plugin_id: str): """Handle drawer item click.""" self.drawer.hide() self._on_plugin_clicked(plugin_id) def _on_search(self): """Handle search box return.""" text = self.search_box.text().strip() if text: self.search_requested.emit(text) print(f"[Taskbar] Search: {text}") def _on_search_text_changed(self, text: str): """Handle search text changes.""" self.search_text = text # Could implement live filtering here def _pulse_animation(self): """Subtle pulse animation on interaction.""" anim = QPropertyAnimation(self, b"minimumHeight") anim.setDuration(150) anim.setStartValue(self.height()) anim.setEndValue(self.height() + 2) anim.setEasingCurve(QEasingCurve.Type.OutQuad) anim.start() def _update_clock(self): """Update clock display.""" from datetime import datetime self.clock_label.setText(datetime.now().strftime("%H:%M")) def _show_context_menu(self, position): """Show right-click context menu.""" menu = QMenu(self) menu.setStyleSheet(""" QMenu { background: rgba(40, 40, 40, 0.95); color: white; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 8px; } QMenu::item { padding: 8px 24px; border-radius: 4px; } QMenu::item:selected { background: rgba(255, 255, 255, 0.1); } """) settings_action = menu.addAction("⚙️ Settings") settings_action.triggered.connect(self._show_settings) menu.addSeparator() hide_action = menu.addAction("🗕 Hide") hide_action.triggered.connect(self.hide) menu.exec(self.mapToGlobal(position)) def _show_settings(self): """Show settings dialog.""" dialog = TaskbarSettingsDialog(self.config, self) if dialog.exec(): self.config = dialog.get_config() self._save_config() self._apply_config() self._refresh_pinned_plugins() def _apply_config(self): """Apply configuration.""" # Set auto-hide timer self.hide_timer.setInterval(self.config.auto_hide_delay) # Position bar screen = QApplication.primaryScreen().geometry() if self.config.position == "bottom": self.move((screen.width() - 600) // 2, screen.height() - 60) else: # top self.move((screen.width() - 600) // 2, 10) # Size self.setFixedSize(600, 50) def _auto_hide(self): """Auto-hide when mouse leaves.""" if self.config.auto_hide and not self.underMouse(): if not hasattr(self, 'drawer') or not self.drawer.isVisible(): self.hide() def enterEvent(self, event): """Mouse entered - stop hide timer.""" self.hide_timer.stop() super().enterEvent(event) def leaveEvent(self, event): """Mouse left - start hide timer.""" if self.config.auto_hide: 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 window.""" 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 def _load_config(self) -> ActivityBarConfig: """Load configuration.""" config_path = Path("config/activity_bar.json") if config_path.exists(): try: data = json.loads(config_path.read_text()) return ActivityBarConfig.from_dict(data) except: pass return ActivityBarConfig() def _save_config(self): """Save configuration.""" config_path = Path("config/activity_bar.json") config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps(self.config.to_dict(), indent=2)) class TaskbarSettingsDialog(QDialog): """Settings dialog for Windows Taskbar.""" def __init__(self, config: ActivityBarConfig, parent=None): super().__init__(parent) self.config = config self.setWindowTitle("Taskbar Settings") self.setMinimumSize(350, 300) self._setup_ui() def _setup_ui(self): """Setup settings UI.""" from PyQt6.QtWidgets import QVBoxLayout, QFormLayout, QDialogButtonBox layout = QVBoxLayout(self) form = QFormLayout() # 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) # Position self.position_combo = QComboBox() self.position_combo.addItems(["Bottom", "Top"]) self.position_combo.setCurrentText(self.config.position.title()) form.addRow("Position:", self.position_combo) # Icon size self.icon_size = QSpinBox() self.icon_size.setRange(24, 48) self.icon_size.setValue(self.config.icon_size) form.addRow("Icon Size:", self.icon_size) 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 config.""" return ActivityBarConfig( enabled=True, position=self.position_combo.currentText().lower(), icon_size=self.icon_size.value(), auto_hide=self.autohide_cb.isChecked(), auto_hide_delay=self.config.auto_hide_delay, pinned_plugins=self.config.pinned_plugins ) # Global instance _taskbar_instance: Optional[WindowsTaskbar] = None def get_activity_bar(plugin_manager=None) -> Optional[WindowsTaskbar]: """Get or create global taskbar instance.""" global _taskbar_instance if _taskbar_instance is None and plugin_manager: _taskbar_instance = WindowsTaskbar(plugin_manager) return _taskbar_instance