""" EU-Utility - Enhanced Activity Bar Windows 11-style taskbar with pinned plugins, app drawer, and search. """ 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, QScrollArea, QGridLayout, QMessageBox, QGraphicsDropShadowEffect ) from PyQt6.QtCore import Qt, QPoint, QSize, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve from PyQt6.QtGui import QMouseEvent, QPainter, QColor, QFont, QIcon, QPixmap, QDrag from PyQt6.QtCore import QMimeData, QByteArray from core.data.sqlite_store import get_sqlite_store @dataclass class ActivityBarConfig: """Activity bar configuration.""" enabled: bool = True position: str = "bottom" icon_size: int = 32 auto_hide: bool = False auto_hide_delay: int = 3000 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', False), auto_hide_delay=data.get('auto_hide_delay', 3000), pinned_plugins=data.get('pinned_plugins', []) ) class DraggablePluginButton(QPushButton): """Plugin button that supports drag-to-pin.""" drag_started = pyqtSignal(str) def __init__(self, plugin_id: str, plugin_name: str, icon_text: str, parent=None): super().__init__(parent) self.plugin_id = plugin_id self.plugin_name = plugin_name self.icon_text = icon_text self.setText(icon_text) self.setFixedSize(40, 40) self.setToolTip(plugin_name) self._setup_style() def _setup_style(self): """Setup button style.""" self.setStyleSheet(""" DraggablePluginButton { background: transparent; color: white; border: none; border-radius: 8px; font-size: 16px; } DraggablePluginButton:hover { background: rgba(255, 255, 255, 0.1); } DraggablePluginButton:pressed { background: rgba(255, 255, 255, 0.05); } """) self.setCursor(Qt.CursorShape.PointingHandCursor) def mousePressEvent(self, event): """Start drag on middle click or with modifier.""" if event.button() == Qt.MouseButton.LeftButton: self.drag_start_pos = event.pos() super().mousePressEvent(event) def mouseMoveEvent(self, event): """Handle drag.""" if not (event.buttons() & Qt.MouseButton.LeftButton): return if not hasattr(self, 'drag_start_pos'): return # Check if dragged far enough if (event.pos() - self.drag_start_pos).manhattanLength() < 10: return # Start drag drag = QDrag(self) mime_data = QMimeData() mime_data.setText(self.plugin_id) mime_data.setData('application/x-plugin-id', QByteArray(self.plugin_id.encode())) drag.setMimeData(mime_data) # Create drag pixmap pixmap = QPixmap(40, 40) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setBrush(QColor(255, 140, 66, 200)) painter.drawRoundedRect(0, 0, 40, 40, 8, 8) painter.setPen(Qt.GlobalColor.white) painter.setFont(QFont("Segoe UI", 14)) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, self.icon_text) painter.end() drag.setPixmap(pixmap) drag.setHotSpot(QPoint(20, 20)) self.drag_started.emit(self.plugin_id) drag.exec(Qt.DropAction.MoveAction) class PinnedPluginsArea(QFrame): """Area for pinned plugins with drop support.""" plugin_pinned = pyqtSignal(str) plugin_unpinned = pyqtSignal(str) plugin_reordered = pyqtSignal(list) # New order of plugin IDs def __init__(self, parent=None): super().__init__(parent) self.pinned_plugins: List[str] = [] self.buttons: Dict[str, DraggablePluginButton] = {} self.setAcceptDrops(True) self._setup_ui() def _setup_ui(self): """Setup UI.""" self.setStyleSheet("background: transparent; border: none;") layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) layout.addStretch() def add_plugin(self, plugin_id: str, plugin_name: str, icon_text: str = "◆"): """Add a pinned plugin.""" if plugin_id in self.pinned_plugins: return self.pinned_plugins.append(plugin_id) btn = DraggablePluginButton(plugin_id, plugin_name, icon_text) btn.clicked.connect(lambda: self._on_plugin_clicked(plugin_id)) # Insert before stretch layout = self.layout() layout.insertWidget(layout.count() - 1, btn) self.buttons[plugin_id] = btn # Log store = get_sqlite_store() store.log_activity('ui', 'plugin_pinned', f"Plugin: {plugin_id}") def remove_plugin(self, plugin_id: str): """Remove a pinned plugin.""" if plugin_id not in self.pinned_plugins: return self.pinned_plugins.remove(plugin_id) if plugin_id in self.buttons: btn = self.buttons[plugin_id] self.layout().removeWidget(btn) btn.deleteLater() del self.buttons[plugin_id] # Log store = get_sqlite_store() store.log_activity('ui', 'plugin_unpinned', f"Plugin: {plugin_id}") def set_plugins(self, plugins: List[tuple]): """Set all pinned plugins.""" # Clear existing for plugin_id in list(self.pinned_plugins): self.remove_plugin(plugin_id) # Add new for plugin_id, plugin_name, icon_text in plugins: self.add_plugin(plugin_id, plugin_name, icon_text) def _on_plugin_clicked(self, plugin_id: str): """Handle plugin click.""" parent = self.window() if parent and hasattr(parent, 'show_plugin'): parent.show_plugin(plugin_id) def dragEnterEvent(self, event): """Accept drag events.""" if event.mimeData().hasText(): event.acceptProposedAction() def dropEvent(self, event): """Handle drop.""" plugin_id = event.mimeData().text() self.plugin_pinned.emit(plugin_id) event.acceptProposedAction() class AppDrawer(QFrame): """App drawer popup with all plugins.""" plugin_launched = pyqtSignal(str) plugin_pin_requested = pyqtSignal(str) def __init__(self, plugin_manager, parent=None): super().__init__(parent) self.plugin_manager = plugin_manager self.search_text = "" self._setup_ui() def _setup_ui(self): """Setup drawer UI.""" self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setFixedSize(420, 500) # Frosted glass effect self.setStyleSheet(""" AppDrawer { background: rgba(32, 32, 32, 0.95); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; } """) # Shadow shadow = QGraphicsDropShadowEffect() shadow.setBlurRadius(30) shadow.setColor(QColor(0, 0, 0, 100)) shadow.setOffset(0, 8) self.setGraphicsEffect(shadow) layout = QVBoxLayout(self) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) # Header header = QLabel("All Plugins") header.setStyleSheet("color: white; font-size: 18px; font-weight: bold;") layout.addWidget(header) # Search box self.search_box = QLineEdit() 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: 10px; padding: 10px 15px; font-size: 14px; } QLineEdit:focus { background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 140, 66, 0.5); } """) self.search_box.textChanged.connect(self._on_search) layout.addWidget(self.search_box) # Plugin grid scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setStyleSheet("background: transparent; border: none;") self.grid_widget = QWidget() self.grid_layout = QGridLayout(self.grid_widget) self.grid_layout.setSpacing(10) self.grid_layout.setContentsMargins(0, 0, 0, 0) scroll.setWidget(self.grid_widget) layout.addWidget(scroll) self._refresh_plugins() def _refresh_plugins(self): """Refresh plugin grid.""" # Clear existing while self.grid_layout.count(): item = self.grid_layout.takeAt(0) if item.widget(): item.widget().deleteLater() if not self.plugin_manager: return all_plugins = self.plugin_manager.get_all_discovered_plugins() # Filter by search filtered = [] for plugin_id, plugin_class in all_plugins.items(): name = plugin_class.name.lower() desc = plugin_class.description.lower() search = self.search_text.lower() if not search or search in name or search in desc: filtered.append((plugin_id, plugin_class)) # Create items cols = 3 for i, (plugin_id, plugin_class) in enumerate(filtered): item = self._create_plugin_item(plugin_id, plugin_class) row = i // cols col = i % cols self.grid_layout.addWidget(item, row, col) self.grid_layout.setColumnStretch(cols, 1) self.grid_layout.setRowStretch((len(filtered) // cols) + 1, 1) def _create_plugin_item(self, plugin_id: str, plugin_class) -> QFrame: """Create a plugin item.""" frame = QFrame() frame.setFixedSize(110, 110) frame.setStyleSheet(""" QFrame { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; } QFrame:hover { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); } """) frame.setCursor(Qt.CursorShape.PointingHandCursor) layout = QVBoxLayout(frame) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(6) # Icon icon = QLabel(getattr(plugin_class, 'icon', '📦')) icon.setStyleSheet("font-size: 28px;") icon.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(icon) # Name name = QLabel(plugin_class.name) name.setStyleSheet("color: white; font-size: 11px; font-weight: bold;") name.setAlignment(Qt.AlignmentFlag.AlignCenter) name.setWordWrap(True) layout.addWidget(name) # Click handler frame.mousePressEvent = lambda event, pid=plugin_id: self._on_plugin_clicked(pid) # Context menu frame.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) frame.customContextMenuRequested.connect( lambda pos, pid=plugin_id: self._show_context_menu(pos, pid) ) return frame def _on_plugin_clicked(self, plugin_id: str): """Handle plugin click.""" self.plugin_launched.emit(plugin_id) self.hide() def _show_context_menu(self, pos, plugin_id: str): """Show 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); } """) pin_action = menu.addAction("📌 Pin to Taskbar") pin_action.triggered.connect(lambda: self.plugin_pin_requested.emit(plugin_id)) menu.exec(self.mapToGlobal(pos)) def _on_search(self, text: str): """Handle search.""" self.search_text = text self._refresh_plugins() class EnhancedActivityBar(QFrame): """Enhanced activity bar with drag-to-pin and search.""" plugin_requested = pyqtSignal(str) search_requested = pyqtSignal(str) settings_requested = pyqtSignal() def __init__(self, plugin_manager, parent=None): super().__init__(parent) self.plugin_manager = plugin_manager self.config = self._load_config() self._setup_ui() self._apply_config() # Load pinned plugins self._load_pinned_plugins() def _setup_ui(self): """Setup activity bar UI.""" self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool | Qt.WindowType.WindowDoesNotAcceptFocus ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setFixedHeight(56) # Main layout layout = QHBoxLayout(self) layout.setContentsMargins(12, 4, 12, 4) layout.setSpacing(8) # Style self.setStyleSheet(""" EnhancedActivityBar { background: rgba(30, 30, 35, 0.9); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 28px; } """) # Start button self.start_btn = QPushButton("⊞") 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: 18px; } QPushButton:hover { background: rgba(255, 255, 255, 0.2); } """) self.start_btn.setToolTip("Open App Drawer") self.start_btn.clicked.connect(self._toggle_drawer) layout.addWidget(self.start_btn) # Search box self.search_box = QLineEdit() self.search_box.setFixedSize(180, 36) self.search_box.setPlaceholderText("Search...") 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 14px; font-size: 13px; } QLineEdit:focus { background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 140, 66, 0.5); } """) self.search_box.returnPressed.connect(self._on_search) 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 self.pinned_area = PinnedPluginsArea() self.pinned_area.plugin_pinned.connect(self._on_plugin_pinned) self.pinned_area.setAcceptDrops(True) layout.addWidget(self.pinned_area) # Spacer layout.addStretch() # Clock self.clock_label = QLabel("12:00") self.clock_label.setStyleSheet("color: rgba(255, 255, 255, 0.7); font-size: 12px;") self.clock_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.clock_label) # Settings button self.settings_btn = QPushButton("⚙️") self.settings_btn.setFixedSize(36, 36) self.settings_btn.setStyleSheet(""" QPushButton { background: transparent; color: rgba(255, 255, 255, 0.7); border: none; border-radius: 6px; font-size: 14px; } QPushButton:hover { background: rgba(255, 255, 255, 0.1); color: white; } """) self.settings_btn.setToolTip("Settings") self.settings_btn.clicked.connect(self.settings_requested.emit) layout.addWidget(self.settings_btn) # Clock timer self.clock_timer = QTimer(self) self.clock_timer.timeout.connect(self._update_clock) self.clock_timer.start(60000) self._update_clock() # Auto-hide timer self.hide_timer = QTimer(self) self.hide_timer.timeout.connect(self.hide) # Drawer self.drawer = None # Enable drag-drop self.setAcceptDrops(True) def _toggle_drawer(self): """Toggle app drawer.""" if self.drawer is None: self.drawer = AppDrawer(self.plugin_manager, self) self.drawer.plugin_launched.connect(self.plugin_requested.emit) self.drawer.plugin_pin_requested.connect(self._pin_plugin) if self.drawer.isVisible(): self.drawer.hide() else: # Position drawer bar_pos = self.pos() if self.config.position == "bottom": self.drawer.move(bar_pos.x(), bar_pos.y() - self.drawer.height() - 10) else: self.drawer.move(bar_pos.x(), bar_pos.y() + self.height() + 10) self.drawer.show() self.drawer.raise_() def _on_search(self): """Handle search.""" text = self.search_box.text().strip() if text: self.search_requested.emit(text) # Log store = get_sqlite_store() store.log_activity('ui', 'search', f"Query: {text}") def _on_plugin_pinned(self, plugin_id: str): """Handle plugin pin.""" self._pin_plugin(plugin_id) def _pin_plugin(self, plugin_id: str): """Pin a plugin to the activity bar.""" if not self.plugin_manager: return all_plugins = self.plugin_manager.get_all_discovered_plugins() if plugin_id not in all_plugins: return plugin_class = all_plugins[plugin_id] if plugin_id not in self.config.pinned_plugins: self.config.pinned_plugins.append(plugin_id) self._save_config() icon_text = getattr(plugin_class, 'icon', '◆') self.pinned_area.add_plugin(plugin_id, plugin_class.name, icon_text) def _unpin_plugin(self, plugin_id: str): """Unpin a plugin.""" if plugin_id in self.config.pinned_plugins: self.config.pinned_plugins.remove(plugin_id) self._save_config() self.pinned_area.remove_plugin(plugin_id) def _load_pinned_plugins(self): """Load pinned plugins from config.""" if not self.plugin_manager: return all_plugins = self.plugin_manager.get_all_discovered_plugins() plugins = [] for plugin_id in self.config.pinned_plugins: if plugin_id in all_plugins: plugin_class = all_plugins[plugin_id] icon_text = getattr(plugin_class, 'icon', '◆') plugins.append((plugin_id, plugin_class.name, icon_text)) self.pinned_area.set_plugins(plugins) def _update_clock(self): """Update clock display.""" from datetime import datetime self.clock_label.setText(datetime.now().strftime("%H:%M")) def _apply_config(self): """Apply configuration.""" screen = QApplication.primaryScreen().geometry() if self.config.position == "bottom": self.move((screen.width() - 700) // 2, screen.height() - 70) else: self.move((screen.width() - 700) // 2, 20) self.setFixedWidth(700) 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)) def enterEvent(self, event): """Mouse entered.""" self.hide_timer.stop() super().enterEvent(event) def leaveEvent(self, event): """Mouse left.""" if self.config.auto_hide: self.hide_timer.start(self.config.auto_hide_delay) 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 getattr(self, '_dragging', False): 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 # Global instance _activity_bar_instance = None def get_activity_bar(plugin_manager=None) -> Optional[EnhancedActivityBar]: """Get or create global activity bar instance.""" global _activity_bar_instance if _activity_bar_instance is None and plugin_manager: _activity_bar_instance = EnhancedActivityBar(plugin_manager) return _activity_bar_instance