EU-Utility/core/activity_bar_enhanced.py

716 lines
24 KiB
Python

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