EU-Utility/core/activity_bar.py

588 lines
20 KiB
Python

"""
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
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 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."""
# 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-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)
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 ===
# 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;")
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)
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_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 _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);
}
""")
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