EU-Utility/core/activity_bar.py

785 lines
28 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, 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