""" 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 from core.icon_manager import get_icon_manager @dataclass class ActivityBarConfig: """Activity bar configuration.""" enabled: bool = True position: str = "bottom" # top, bottom, custom x: int = 100 # custom X position y: int = 800 # custom Y position width: int = 800 # custom width icon_size: int = 32 auto_hide: bool = False # Disabled by default for easier use auto_hide_delay: int = 3000 # milliseconds auto_show_on_focus: bool = False background_opacity: int = 90 # 0-100 show_search: bool = True show_clock: bool = True 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, 'x': self.x, 'y': self.y, 'width': self.width, 'icon_size': self.icon_size, 'auto_hide': self.auto_hide, 'auto_hide_delay': self.auto_hide_delay, 'auto_show_on_focus': self.auto_show_on_focus, 'background_opacity': self.background_opacity, 'show_search': self.show_search, 'show_clock': self.show_clock, 'pinned_plugins': self.pinned_plugins } @classmethod def from_dict(cls, data): return cls( enabled=data.get('enabled', True), position=data.get('position', 'bottom'), x=data.get('x', 100), y=data.get('y', 800), width=data.get('width', 800), icon_size=data.get('icon_size', 32), auto_hide=data.get('auto_hide', False), auto_hide_delay=data.get('auto_hide_delay', 3000), auto_show_on_focus=data.get('auto_show_on_focus', False), background_opacity=data.get('background_opacity', 90), show_search=data.get('show_search', True), show_clock=data.get('show_clock', True), 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 with proper icon - 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.icon_manager = get_icon_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 with draggable and resizable features.""" # Main layout with minimal margins self.main_layout = QHBoxLayout(self) self.main_layout.setContentsMargins(8, 4, 8, 4) self.main_layout.setSpacing(4) # Apply opacity from config self._apply_opacity() # === DRAG HANDLE (invisible, left side) === self.drag_handle = QFrame() self.drag_handle.setFixedSize(8, 40) self.drag_handle.setStyleSheet("background: transparent; cursor: move;") self.drag_handle.setToolTip("Drag to move") self.main_layout.addWidget(self.drag_handle) # === START BUTTON (Windows-style icon) === self.start_btn = QPushButton() self.start_btn.setFixedSize(40, 40) self.start_btn.setIcon(self.icon_manager.get_icon("grid")) self.start_btn.setIconSize(QSize(20, 20)) self.start_btn.setStyleSheet(""" QPushButton { background: rgba(255, 255, 255, 0.1); color: white; border: none; border-radius: 8px; } 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) self.main_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) self.main_layout.addWidget(self.search_box) # Search visibility self.search_box.setVisible(self.config.show_search) # Separator separator = QFrame() separator.setFixedSize(1, 24) separator.setStyleSheet("background: rgba(255, 255, 255, 0.1);") self.main_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) self.main_layout.addWidget(self.pinned_container) # Spacer to push icons together self.main_layout.addStretch() # === CLOCK AREA === self.clock_widget = QWidget() clock_layout = QHBoxLayout(self.clock_widget) clock_layout.setContentsMargins(0, 0, 0, 0) clock_layout.setSpacing(4) # Clock icon self.clock_icon = QLabel() clock_pixmap = self.icon_manager.get_pixmap("clock", size=14) self.clock_icon.setPixmap(clock_pixmap) self.clock_icon.setStyleSheet("padding-right: 4px;") clock_layout.addWidget(self.clock_icon) # Clock time 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) clock_layout.addWidget(self.clock_label) self.main_layout.addWidget(self.clock_widget) # Clock visibility self.clock_widget.setVisible(self.config.show_clock) # === RESIZE HANDLE (right side) === self.resize_handle = QFrame() self.resize_handle.setFixedSize(8, 40) self.resize_handle.setStyleSheet("background: transparent; cursor: size-hor-cursor;") self.resize_handle.setToolTip("Drag to resize") self.main_layout.addWidget(self.resize_handle) # 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() # Set initial size and position self._apply_size_and_position() 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_name = getattr(plugin_class, 'icon_name', 'grid') btn.setIcon(self.icon_manager.get_icon(icon_name)) btn.setIconSize(QSize(size - 8, size - 8)) btn.setStyleSheet(f""" QPushButton {{ background: transparent; color: white; border: none; border-radius: 6px; }} 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(20, 31, 35, 0.95); border: 1px solid rgba(255, 140, 66, 0.15); 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 mousePressEvent(self, event: QMouseEvent): """Handle mouse press for dragging.""" if event.button() == Qt.MouseButton.LeftButton: # Check if clicking on drag handle if self.drag_handle.geometry().contains(event.pos()): self._dragging = True self._drag_offset = event.globalPosition().toPoint() - self.frameGeometry().topLeft() event.accept() # Check if clicking on resize handle elif self.resize_handle.geometry().contains(event.pos()): self._resizing = True self._resize_start_x = event.globalPosition().x() self._resize_start_width = self.width() event.accept() else: super().mousePressEvent(event) def mouseMoveEvent(self, event: QMouseEvent): """Handle mouse move for dragging and resizing.""" if self._dragging: new_pos = event.globalPosition().toPoint() - self._drag_offset self.move(new_pos) # Save position self.config.x = new_pos.x() self.config.y = new_pos.y() self._save_config() event.accept() elif getattr(self, '_resizing', False): delta = event.globalPosition().x() - self._resize_start_x new_width = max(400, self._resize_start_width + delta) self.setFixedWidth(int(new_width)) self.config.width = int(new_width) self._save_config() event.accept() else: super().mouseMoveEvent(event) def mouseReleaseEvent(self, event: QMouseEvent): """Handle mouse release.""" if event.button() == Qt.MouseButton.LeftButton: self._dragging = False self._resizing = False super().mouseReleaseEvent(event) def _apply_opacity(self): """Apply background opacity from config.""" opacity = self.config.background_opacity / 100.0 # Create a background widget with opacity bg_color = f"rgba(20, 31, 35, {opacity})" self.setStyleSheet(f""" WindowsTaskbar {{ background: {bg_color}; border: 1px solid rgba(255, 140, 66, 0.1); border-radius: 12px; }} """) def _apply_size_and_position(self): """Apply saved size and position.""" self.setFixedHeight(56) self.setFixedWidth(self.config.width) if self.config.position == "custom": self.move(self.config.x, self.config.y) elif self.config.position == "bottom": screen = QApplication.primaryScreen().geometry() self.move( (screen.width() - self.config.width) // 2, screen.height() - 80 ) elif self.config.position == "top": screen = QApplication.primaryScreen().geometry() self.move( (screen.width() - self.config.width) // 2, 20 ) def _create_drawer_item(self, plugin_id: str, plugin_class) -> QPushButton: """Create a drawer item (like Start menu app).""" icon_name = getattr(plugin_class, 'icon_name', 'grid') btn = QPushButton(f" {plugin_class.name}") btn.setIcon(self.icon_manager.get_icon(icon_name)) btn.setIconSize(QSize(20, 20)) btn.setFixedHeight(44) btn.setStyleSheet(""" QPushButton { background: transparent; color: white; border: none; border-radius: 8px; text-align: left; font-size: 13px; padding-left: 12px; } 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(20, 31, 35, 0.95); color: white; border: 1px solid rgba(255, 140, 66, 0.15); border-radius: 8px; padding: 8px; } QMenu::item { padding: 8px 24px; border-radius: 4px; } QMenu::item:selected { background: rgba(255, 140, 66, 0.2); } """) # Toggle search search_action = menu.addAction("☑ Search" if self.config.show_search else "☐ Search") search_action.triggered.connect(self._toggle_search) # Toggle clock clock_action = menu.addAction("☑ Clock" if self.config.show_clock else "☐ Clock") clock_action.triggered.connect(self._toggle_clock) menu.addSeparator() # Settings settings_action = menu.addAction("Settings...") settings_action.triggered.connect(self._show_settings) # Reset position reset_action = menu.addAction("Reset Position") reset_action.triggered.connect(self._reset_position) menu.addSeparator() # Hide hide_action = menu.addAction("Hide") hide_action.triggered.connect(self.hide) menu.exec(self.mapToGlobal(position)) def _toggle_search(self): """Toggle search box visibility.""" self.config.show_search = not self.config.show_search self.search_box.setVisible(self.config.show_search) self._save_config() def _toggle_clock(self): """Toggle clock visibility.""" self.config.show_clock = not self.config.show_clock self.clock_widget.setVisible(self.config.show_clock) self._save_config() def _reset_position(self): """Reset bar position to default.""" self.config.position = "bottom" self.config.x = 100 self.config.y = 800 self._apply_size_and_position() self._save_config() 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) # Apply opacity self._apply_opacity() # Apply size and position self._apply_size_and_position() # Show/hide search and clock self.search_box.setVisible(self.config.show_search) self.clock_widget.setVisible(self.config.show_clock) 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 _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("Activity Bar Settings") self.setMinimumSize(400, 450) self._setup_ui() def _setup_ui(self): """Setup settings UI.""" from PyQt6.QtWidgets import QVBoxLayout, QFormLayout, QDialogButtonBox, QGroupBox layout = QVBoxLayout(self) # Appearance Group appear_group = QGroupBox("Appearance") appear_form = QFormLayout(appear_group) # Opacity self.opacity_slider = QSlider(Qt.Orientation.Horizontal) self.opacity_slider.setRange(20, 100) self.opacity_slider.setValue(self.config.background_opacity) self.opacity_label = QLabel(f"{self.config.background_opacity}%") self.opacity_slider.valueChanged.connect(lambda v: self.opacity_label.setText(f"{v}%")) opacity_layout = QHBoxLayout() opacity_layout.addWidget(self.opacity_slider) opacity_layout.addWidget(self.opacity_label) appear_form.addRow("Background Opacity:", opacity_layout) # Icon size self.icon_size = QSpinBox() self.icon_size.setRange(24, 48) self.icon_size.setValue(self.config.icon_size) appear_form.addRow("Icon Size:", self.icon_size) layout.addWidget(appear_group) # Features Group features_group = QGroupBox("Features") features_form = QFormLayout(features_group) # Show search self.show_search_cb = QCheckBox("Show search box") self.show_search_cb.setChecked(self.config.show_search) features_form.addRow(self.show_search_cb) # Show clock self.show_clock_cb = QCheckBox("Show clock") self.show_clock_cb.setChecked(self.config.show_clock) features_form.addRow(self.show_clock_cb) layout.addWidget(features_group) # Position Group pos_group = QGroupBox("Position") pos_form = QFormLayout(pos_group) # Position self.position_combo = QComboBox() self.position_combo.addItems(["Bottom", "Top", "Custom"]) self.position_combo.setCurrentText(self.config.position.title()) pos_form.addRow("Position:", self.position_combo) # Width self.width_spin = QSpinBox() self.width_spin.setRange(400, 1600) self.width_spin.setValue(self.config.width) pos_form.addRow("Width:", self.width_spin) layout.addWidget(pos_group) # Behavior Group behav_group = QGroupBox("Behavior") behav_form = QFormLayout(behav_group) # Auto-hide self.autohide_cb = QCheckBox("Auto-hide when not in use") self.autohide_cb.setChecked(self.config.auto_hide) behav_form.addRow(self.autohide_cb) layout.addWidget(behav_group) layout.addStretch() # 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(), x=self.config.x, y=self.config.y, width=self.width_spin.value(), icon_size=self.icon_size.value(), auto_hide=self.autohide_cb.isChecked(), auto_hide_delay=self.config.auto_hide_delay, auto_show_on_focus=self.config.auto_show_on_focus, background_opacity=self.opacity_slider.value(), show_search=self.show_search_cb.isChecked(), show_clock=self.show_clock_cb.isChecked(), 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