diff --git a/API.md b/API.md new file mode 100644 index 0000000..533d6bd --- /dev/null +++ b/API.md @@ -0,0 +1,991 @@ +# EU-Utility API Reference + +> Complete API documentation for plugin developers + +**Version:** 2.1.0 +**Last Updated:** 2026-02-16 + +--- + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [PluginAPI](#pluginapi) - Core services access +3. [WidgetAPI](#widgetapi) - Overlay widget management +4. [ExternalAPI](#externalapi) - REST endpoints and webhooks +5. [Event Bus](#event-bus) - Pub/sub event system +6. [BasePlugin Class](#baseplugin-class) - Plugin base class reference +7. [Nexus API](#nexus-api) - Entropia Nexus integration +8. [Code Examples](#code-examples) - Practical examples +9. [Best Practices](#best-practices) + +--- + +## Getting Started + +### Importing the API + +```python +from core.api import get_api + +class MyPlugin(BasePlugin): + def initialize(self): + self.api = get_api() +``` + +### API Availability + +Always check if a service is available before using it: + +```python +if self.api.ocr_available(): + text = self.api.recognize_text() +``` + +--- + +## PluginAPI + +The PluginAPI provides access to all core EU-Utility services. + +### Window Manager + +```python +# Get EU window information +window = api.get_eu_window() +if window: + print(f"Position: {window['x']}, {window['y']}") + print(f"Size: {window['width']}x{window['height']}") + print(f"Focused: {window['is_focused']}") + +# Check if EU is focused +if api.is_eu_focused(): + api.play_sound("alert.wav") + +# Bring EU to front +api.bring_eu_to_front() + +# Check if EU window is visible +is_visible = api.is_eu_visible() +``` + +### OCR Service + +```python +# Check OCR availability +if api.ocr_available(): + # Read text from screen region + text = api.recognize_text(region=(100, 100, 200, 50)) + print(f"Found: {text}") + +# Alternative: Use BasePlugin helper method +result = self.ocr_capture(region=(x, y, width, height)) +# Returns: {'text': str, 'confidence': float, 'error': str or None} +``` + +### Screenshot Service + +```python +# Check availability +if api.screenshot_available(): + # Capture full screen + img = api.capture_screen() + + # Capture specific region + img = api.capture_screen(region=(100, 100, 400, 300)) + + # Capture and save + img = api.capture_screen( + region=(100, 100, 400, 300), + save_path="screenshot.png" + ) + +# Alternative: Use BasePlugin helper +img = self.capture_screen(full_screen=True) +img = self.capture_region(x, y, width, height) +``` + +### Log Reader + +```python +# Read recent log lines +lines = api.read_log_lines(count=100) + +# Read logs since timestamp +from datetime import datetime, timedelta +recent = api.read_log_since(datetime.now() - timedelta(minutes=5)) + +# Alternative: Use BasePlugin helper +lines = self.read_log(lines=50, filter_text="loot") +``` + +### Data Store + +```python +# Store data (scoped to plugin) +api.set_data("key", value) + +# Retrieve with default +value = api.get_data("key", default=None) + +# Delete data +api.delete_data("key") + +# Alternative: Use BasePlugin helpers +self.save_data("key", value) +data = self.load_data("key", default=None) +self.delete_data("key") +``` + +### HTTP Client + +```python +# GET request with caching +result = api.http_get( + "https://api.example.com/data", + cache=True, + cache_duration=3600 # 1 hour +) + +if result['success']: + data = result['data'] +else: + error = result['error'] + +# POST request +result = api.http_post( + "https://api.example.com/submit", + data={"key": "value"} +) + +# Alternative: Use BasePlugin helper +response = self.http_get(url, cache_ttl=300, headers={}) +``` + +### Audio + +```python +# Play sound file +api.play_sound("assets/sounds/alert.wav", volume=0.7) + +# Simple beep +api.beep() + +# Alternative: Use BasePlugin helpers +self.play_sound("hof") # Predefined: 'hof', 'skill_gain', 'alert' +self.set_volume(0.8) +volume = self.get_volume() +``` + +### Notifications + +```python +# Show toast notification +api.show_notification( + title="Loot Alert!", + message="You found something valuable!", + duration=5000, # milliseconds + sound=True +) + +# Alternative: Use BasePlugin helpers +self.notify(title, message, notification_type='info', sound=False) +self.notify_info(title, message) +self.notify_success(title, message) +self.notify_warning(title, message) +self.notify_error(title, message, sound=True) +``` + +### Clipboard + +```python +# Copy to clipboard +api.copy_to_clipboard("Text to copy") + +# Paste from clipboard +text = api.paste_from_clipboard() + +# Alternative: Use BasePlugin helpers +self.copy_to_clipboard(text) +text = self.paste_from_clipboard() +``` + +### Background Tasks + +```python +# Run function in background +def heavy_computation(data): + # Long running task + import time + time.sleep(2) + return f"Processed: {data}" + +def on_complete(result): + print(f"Done: {result}") + +def on_error(error): + print(f"Error: {error}") + +task_id = api.run_task( + heavy_computation, + "my data", + callback=on_complete, + error_handler=on_error +) + +# Cancel task +api.cancel_task(task_id) + +# Alternative: Use BasePlugin helper +self.run_in_background(func, *args, priority='normal', + on_complete=cb, on_error=err_cb) +``` + +--- + +## WidgetAPI + +Manage overlay widgets - floating UI components. + +### Getting Started + +```python +from core.api import get_widget_api + +widget_api = get_widget_api() + +# Create widget +widget = widget_api.create_widget( + name="loot_tracker", + title="Loot Tracker", + size=(400, 300), + position=(100, 100) +) + +widget.show() +``` + +### Widget Operations + +```python +# Show/hide +widget.show() +widget.hide() + +# Position +widget.move(500, 200) +x, y = widget.position + +# Size +widget.resize(400, 300) +width, height = widget.size + +# Opacity (0.0 - 1.0) +widget.set_opacity(0.8) + +# Lock/unlock (prevent dragging) +widget.set_locked(True) + +# Minimize/restore +widget.minimize() +widget.restore() + +# Close +widget.close() +``` + +### Widget Management + +```python +# Get existing widget +widget = widget_api.get_widget("loot_tracker") + +# Show/hide specific widget +widget_api.show_widget("loot_tracker") +widget_api.hide_widget("loot_tracker") + +# Close widget +widget_api.close_widget("loot_tracker") + +# Global operations +widget_api.show_all_widgets() +widget_api.hide_all_widgets() +widget_api.close_all_widgets() +widget_api.set_all_opacity(0.8) +widget_api.lock_all() +widget_api.unlock_all() +``` + +### Layout Helpers + +```python +# Arrange widgets +widget_api.arrange_widgets(layout="grid", spacing=10) +widget_api.arrange_widgets(layout="horizontal") +widget_api.arrange_widgets(layout="vertical") +widget_api.arrange_widgets(layout="cascade") + +# Snap to grid +widget_api.snap_to_grid(grid_size=10) +``` + +### Widget Events + +```python +# Handle events +widget.on('moved', lambda data: print(f"Moved to {data['x']}, {data['y']}")) +widget.on('resized', lambda data: print(f"Sized to {data['width']}x{data['height']}")) +widget.on('closing', lambda: print("Widget closing")) +widget.on('closed', lambda: print("Widget closed")) +widget.on('update', lambda data: print(f"Update: {data}")) +``` + +--- + +## ExternalAPI + +REST endpoints, webhooks, and third-party integrations. + +### Getting Started + +```python +from core.api import get_external_api + +ext = get_external_api() + +# Start server +ext.start_server(port=8080) + +# Check status +print(ext.get_status()) +``` + +### REST Endpoints + +```python +# Using decorator +@ext.endpoint("stats", methods=["GET"]) +def get_stats(): + return {"kills": 100, "loot": "50 PED"} + +@ext.endpoint("loot", methods=["POST"]) +def record_loot(data): + save_loot(data) + return {"status": "saved"} + +# Programmatic registration +def get_stats_handler(params): + return {"kills": 100} + +ext.register_endpoint("stats", get_stats_handler, methods=["GET"]) + +# Unregister +ext.unregister_endpoint("stats") +``` + +### Incoming Webhooks + +```python +# Register webhook handler +def handle_discord(payload): + print(f"Discord: {payload}") + return {"status": "ok"} + +ext.register_webhook( + name="discord", + handler=handle_discord, + secret="my_secret" # Optional HMAC verification +) + +# POST to: http://localhost:8080/webhook/discord +``` + +### Outgoing Webhooks + +```python +# POST to external webhook +result = ext.post_webhook( + "https://discord.com/api/webhooks/...", + {"content": "Hello from EU-Utility!"} +) + +if result['success']: + print("Sent!") +else: + print(f"Error: {result['error']}") +``` + +--- + +## Event Bus + +Typed publish-subscribe event system for inter-plugin communication. + +### Event Types + +```python +from core.event_bus import ( + SkillGainEvent, + LootEvent, + DamageEvent, + GlobalEvent, + ChatEvent, + EconomyEvent, + SystemEvent +) +``` + +### Publishing Events + +```python +from core.event_bus import LootEvent, SkillGainEvent + +# Publish loot event +self.publish_typed(LootEvent( + mob_name="Daikiba", + items=[{"name": "Animal Oil", "value": 0.05}], + total_tt_value=0.05 +)) + +# Publish skill gain +self.publish_typed(SkillGainEvent( + skill_name="Rifle", + skill_value=25.5, + gain_amount=0.01 +)) + +# Legacy event publishing +api.publish("my_plugin.event", {"data": "value"}) +``` + +### Subscribing to Events + +```python +from core.event_bus import LootEvent, SkillGainEvent + +class MyPlugin(BasePlugin): + def initialize(self): + # Subscribe to loot events + self.sub_id = self.subscribe_typed( + LootEvent, + self.on_loot + ) + + # Subscribe with filtering + self.sub_id2 = self.subscribe_typed( + LootEvent, + self.on_dragon_loot, + mob_types=["Dragon", "Drake"] + ) + + # Subscribe to specific skills + self.sub_id3 = self.subscribe_typed( + SkillGainEvent, + self.on_combat_skill, + skill_names=["Rifle", "Pistol", "Melee"] + ) + + def on_loot(self, event: LootEvent): + print(f"Loot from {event.mob_name}: {event.items}") + + def on_dragon_loot(self, event: LootEvent): + print(f"Dragon loot! Total TT: {event.total_tt_value}") + + def shutdown(self): + # Clean up subscriptions + self.unsubscribe_typed(self.sub_id) + self.unsubscribe_typed(self.sub_id2) + self.unsubscribe_typed(self.sub_id3) + # Or: self.unsubscribe_all_typed() +``` + +### Event Attributes + +| Event | Attributes | +|-------|------------| +| `SkillGainEvent` | `skill_name`, `skill_value`, `gain_amount` | +| `LootEvent` | `mob_name`, `items`, `total_tt_value`, `position` | +| `DamageEvent` | `damage_amount`, `damage_type`, `is_critical`, `target_name` | +| `GlobalEvent` | `player_name`, `achievement_type`, `value`, `item_name` | +| `ChatEvent` | `channel`, `sender`, `message` | +| `EconomyEvent` | `transaction_type`, `amount`, `currency`, `description` | +| `SystemEvent` | `message`, `severity` | + +### Legacy Event Bus + +```python +# Subscribe +sub_id = api.subscribe("loot", on_loot_callback) + +# Unsubscribe +api.unsubscribe(sub_id) + +# Publish +api.publish("event_type", {"key": "value"}) + +# Get history +history = api.get_event_history("loot", limit=10) +``` + +--- + +## BasePlugin Class + +Complete reference for the base plugin class. + +### Required Attributes + +```python +class MyPlugin(BasePlugin): + name = "My Plugin" # Display name + version = "1.0.0" # Version string + author = "Your Name" # Author name + description = "..." # Short description +``` + +### Optional Attributes + +```python +class MyPlugin(BasePlugin): + icon = "path/to/icon.png" # Icon path + hotkey = "ctrl+shift+y" # Legacy single hotkey + hotkeys = [ # Multi-hotkey format + { + 'action': 'toggle', + 'description': 'Toggle My Plugin', + 'default': 'ctrl+shift+m', + 'config_key': 'myplugin_toggle' + } + ] + enabled = True # Start enabled + + dependencies = { # Dependencies + 'pip': ['requests', 'numpy'], + 'plugins': ['other_plugin'], + 'optional': {'pillow': 'Image processing'} + } +``` + +### Lifecycle Methods + +```python +def initialize(self) -> None: + """Called when plugin is loaded.""" + self.api = get_api() + self.log_info("Initialized!") + +def get_ui(self) -> QWidget: + """Return the plugin's UI widget.""" + widget = QWidget() + # ... setup UI ... + return widget + +def on_show(self) -> None: + """Called when overlay becomes visible.""" + pass + +def on_hide(self) -> None: + """Called when overlay is hidden.""" + pass + +def on_hotkey(self) -> None: + """Called when hotkey is pressed.""" + pass + +def shutdown(self) -> None: + """Called when app is closing. Cleanup resources.""" + self.unsubscribe_all_typed() + super().shutdown() +``` + +### Config Methods + +```python +# Get config value +theme = self.get_config('theme', default='dark') + +# Set config value +self.set_config('theme', 'light') +``` + +### Logging Methods + +```python +self.log_debug("Debug message") +self.log_info("Info message") +self.log_warning("Warning message") +self.log_error("Error message") +``` + +### Utility Methods + +```python +# Format currency +ped_text = self.format_ped(123.45) # "123.45 PED" +pec_text = self.format_pec(50) # "50 PEC" + +# Calculate DPP +dpp = self.calculate_dpp(damage=45.0, ammo=100, decay=2.5) + +# Calculate markup +markup = self.calculate_markup(price=150.0, tt=100.0) # 150.0% +``` + +--- + +## Nexus API + +Entropia Nexus integration for game data. + +### Searching + +```python +# Search for items +results = self.nexus_search("ArMatrix", entity_type="items") + +# Search for mobs +mobs = self.nexus_search("Atrox", entity_type="mobs") + +# Search for locations +locations = self.nexus_search("Fort", entity_type="locations") +``` + +### Entity Types + +- `items`, `weapons`, `armors` +- `mobs`, `pets` +- `blueprints`, `materials` +- `locations`, `teleporters`, `shops`, `vendors`, `planets`, `areas` +- `skills` +- `enhancers`, `medicaltools`, `finders`, `excavators`, `refiners` +- `vehicles`, `decorations`, `furniture` +- `storagecontainers`, `strongboxes` + +### Getting Details + +```python +# Get item details +details = self.nexus_get_item_details("armatrix_lp-35") +if details: + print(f"Name: {details['name']}") + print(f"TT Value: {details['tt_value']} PED") + +# Get market data +market = self.nexus_get_market_data("armatrix_lp-35") +if market: + print(f"Current markup: {market['current_markup']:.1f}%") +``` + +--- + +## Code Examples + +### Example 1: Simple Calculator Plugin + +```python +from plugins.base_plugin import BasePlugin +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QLineEdit, QPushButton +) + +class MarkupCalculatorPlugin(BasePlugin): + """Calculate markup percentages.""" + + name = "Markup Calculator" + version = "1.0.0" + author = "Tutorial" + description = "Calculate item markup" + hotkey = "ctrl+shift+m" + + def initialize(self): + self.log_info("Markup Calculator initialized!") + + def get_ui(self): + widget = QWidget() + layout = QVBoxLayout(widget) + + # Title + title = QLabel("Markup Calculator") + title.setStyleSheet("color: #4a9eff; font-size: 18px; font-weight: bold;") + layout.addWidget(title) + + # TT Value input + layout.addWidget(QLabel("TT Value:")) + self.tt_input = QLineEdit() + self.tt_input.setPlaceholderText("100.00") + layout.addWidget(self.tt_input) + + # Market Price input + layout.addWidget(QLabel("Market Price:")) + self.price_input = QLineEdit() + self.price_input.setPlaceholderText("150.00") + layout.addWidget(self.price_input) + + # Calculate button + calc_btn = QPushButton("Calculate") + calc_btn.clicked.connect(self.calculate) + layout.addWidget(calc_btn) + + # Result + self.result_label = QLabel("Markup: -") + self.result_label.setStyleSheet("color: #ffc107; font-size: 16px;") + layout.addWidget(self.result_label) + + layout.addStretch() + return widget + + def calculate(self): + try: + tt = float(self.tt_input.text() or 0) + price = float(self.price_input.text() or 0) + markup = self.calculate_markup(price, tt) + self.result_label.setText(f"Markup: {markup:.1f}%") + except ValueError: + self.result_label.setText("Invalid input") +``` + +### Example 2: Event-Driven Tracker + +```python +from plugins.base_plugin import BasePlugin +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QListWidget +from core.event_bus import LootEvent, SkillGainEvent + +class ActivityTrackerPlugin(BasePlugin): + """Track recent activity from events.""" + + name = "Activity Tracker" + version = "1.0.0" + + def initialize(self): + self.subscriptions = [] + + # Subscribe to multiple event types + sub1 = self.subscribe_typed(LootEvent, self.on_loot) + sub2 = self.subscribe_typed(SkillGainEvent, self.on_skill) + + self.subscriptions.extend([sub1, sub2]) + self.log_info("Activity Tracker initialized") + + def get_ui(self): + widget = QWidget() + layout = QVBoxLayout(widget) + + layout.addWidget(QLabel("Recent Activity:")) + + self.activity_list = QListWidget() + layout.addWidget(self.activity_list) + + return widget + + def on_loot(self, event: LootEvent): + text = f"Loot: {event.mob_name} - {event.total_tt_value:.2f} PED" + self.activity_list.insertItem(0, text) + + def on_skill(self, event: SkillGainEvent): + text = f"Skill: {event.skill_name} +{event.gain_amount:.4f}" + self.activity_list.insertItem(0, text) + + def shutdown(self): + for sub_id in self.subscriptions: + self.unsubscribe_typed(sub_id) +``` + +### Example 3: Background Task Plugin + +```python +from plugins.base_plugin import BasePlugin +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QPushButton, + QLabel, QProgressBar +) + +class DataFetcherPlugin(BasePlugin): + """Fetch data in background.""" + + name = "Data Fetcher" + version = "1.0.0" + + def initialize(self): + self.log_info("Data Fetcher initialized") + + def get_ui(self): + widget = QWidget() + layout = QVBoxLayout(widget) + + self.status_label = QLabel("Ready") + layout.addWidget(self.status_label) + + self.progress = QProgressBar() + self.progress.setRange(0, 0) # Indeterminate + self.progress.hide() + layout.addWidget(self.progress) + + fetch_btn = QPushButton("Fetch Data") + fetch_btn.clicked.connect(self.fetch_data) + layout.addWidget(fetch_btn) + + layout.addStretch() + return widget + + def fetch_data(self): + self.status_label.setText("Fetching...") + self.progress.show() + + # Run in background + self.run_in_background( + self._fetch_from_api, + priority='normal', + on_complete=self._on_fetch_complete, + on_error=self._on_fetch_error + ) + + def _fetch_from_api(self): + # This runs in background thread + import time + time.sleep(2) # Simulate API call + return {"items": ["A", "B", "C"], "count": 3} + + def _on_fetch_complete(self, result): + # This runs in main thread - safe to update UI + self.progress.hide() + self.status_label.setText(f"Got {result['count']} items") + self.notify_success("Data Fetched", f"Retrieved {result['count']} items") + + def _on_fetch_error(self, error): + self.progress.hide() + self.status_label.setText(f"Error: {error}") + self.notify_error("Fetch Failed", str(error)) +``` + +--- + +## Best Practices + +### 1. Always Use BasePlugin Helpers + +```python +# ✅ Good - Uses helper methods +self.save_data("key", value) +data = self.load_data("key", default) + +# ❌ Avoid - Direct API access when helper exists +self.api.set_data("key", value) +``` + +### 2. Handle Errors Gracefully + +```python +def initialize(self): + try: + self.api = get_api() + self.log_info("API connected") + except Exception as e: + self.log_error(f"Failed to get API: {e}") + # Continue with limited functionality +``` + +### 3. Clean Up Resources + +```python +def shutdown(self): + # Unsubscribe from events + self.unsubscribe_all_typed() + + # Save any pending data + self.save_data("pending", self.pending_data) + + super().shutdown() +``` + +### 4. Don't Block the Main Thread + +```python +# ✅ Good - Use background tasks +def heavy_operation(self): + self.run_in_background( + self._do_heavy_work, + on_complete=self._update_ui + ) + +# ❌ Avoid - Blocks UI +def heavy_operation(self): + result = self._do_heavy_work() # Blocks! + self._update_ui(result) +``` + +### 5. Use Type Hints + +```python +from typing import Optional, Dict, Any, List +from core.event_bus import LootEvent + +def on_loot(self, event: LootEvent) -> None: + items: List[Dict[str, Any]] = event.items + total: float = event.total_tt_value +``` + +### 6. Follow Naming Conventions + +```python +# ✅ Good +class LootTrackerPlugin(BasePlugin): + def on_loot_received(self): + pass + +# ❌ Avoid +class lootTracker(BasePlugin): + def loot(self): + pass +``` + +### 7. Document Your Plugin + +```python +class MyPlugin(BasePlugin): + """ + Brief description of what this plugin does. + + Features: + - Feature 1 + - Feature 2 + + Usage: + 1. Step 1 + 2. Step 2 + + Hotkeys: + - Ctrl+Shift+Y: Toggle plugin + """ +``` + +--- + +## Error Handling + +All APIs provide specific exceptions: + +```python +from core.api import ( + PluginAPIError, + ServiceNotAvailableError, + ExternalAPIError +) + +try: + text = api.recognize_text((0, 0, 100, 100)) +except ServiceNotAvailableError: + print("OCR not available") +except PluginAPIError as e: + print(f"API error: {e}") +``` + +--- + +## See Also + +- [Plugin Development Guide](./docs/PLUGIN_DEVELOPMENT.md) - Detailed plugin tutorial +- [Architecture Overview](./ARCHITECTURE.md) - System architecture +- [Nexus API Reference](./docs/NEXUS_API_REFERENCE.md) - Entropia Nexus API +- [Contributing](./CONTRIBUTING.md) - How to contribute diff --git a/core/activity_bar.py b/core/activity_bar.py index b3358ad..55b463a 100644 --- a/core/activity_bar.py +++ b/core/activity_bar.py @@ -1,10 +1,7 @@ """ -EU-Utility - Windows Taskbar Style Activity Bar -=============================================== +EU-Utility - Enhanced Activity Bar -Windows 11-style taskbar for in-game overlay. -Features: Transparent background, search box, pinned plugins, -clean minimal design that expands as needed. +Windows 11-style taskbar with pinned plugins, app drawer, and search. """ import json @@ -15,29 +12,24 @@ from dataclasses import dataclass, asdict from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QLineEdit, QMenu, QDialog, QSlider, QComboBox, - QCheckBox, QSpinBox, QApplication, QSizePolicy + 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 +from PyQt6.QtGui import QMouseEvent, QPainter, QColor, QFont, QIcon, QPixmap, QDrag +from PyQt6.QtCore import QMimeData, QByteArray -from core.icon_manager import get_icon_manager +from core.data.sqlite_store import get_sqlite_store @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 + position: str = "bottom" 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 + auto_hide: bool = False + auto_hide_delay: int = 3000 pinned_plugins: List[str] = None def __post_init__(self): @@ -48,16 +40,9 @@ class ActivityBarConfig: 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 } @@ -66,482 +51,354 @@ class ActivityBarConfig: 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. +class DraggablePluginButton(QPushButton): + """Plugin button that supports drag-to-pin.""" - 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 - """ + drag_started = pyqtSignal(str) - widget_requested = pyqtSignal(str) - search_requested = pyqtSignal(str) - - def __init__(self, plugin_manager, parent=None): + def __init__(self, plugin_id: str, plugin_name: str, icon_text: str, 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() + 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_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); + def _setup_style(self): + """Setup button style.""" + self.setStyleSheet(""" + DraggablePluginButton { + background: transparent; color: white; border: none; border-radius: 8px; + font-size: 16px; } - QPushButton:hover { - background: rgba(255, 255, 255, 0.2); + DraggablePluginButton:hover { + background: rgba(255, 255, 255, 0.1); } - QPushButton:pressed { - background: rgba(255, 255, 255, 0.15); + DraggablePluginButton:pressed { + background: rgba(255, 255, 255, 0.05); } """) - self.start_btn.setToolTip("Open App Drawer") - self.start_btn.clicked.connect(self._toggle_drawer) - self.main_layout.addWidget(self.start_btn) + 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 - # === SEARCH BOX (Windows 11 style) === + 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.setFixedSize(200, 36) - self.search_box.setPlaceholderText("Search plugins...") + 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); + border-radius: 10px; + padding: 10px 15px; + font-size: 14px; } QLineEdit:focus { - background: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.12); 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) + self.search_box.textChanged.connect(self._on_search) + layout.addWidget(self.search_box) - # Search visibility - self.search_box.setVisible(self.config.show_search) + # Plugin grid + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent; border: none;") - # Separator - separator = QFrame() - separator.setFixedSize(1, 24) - separator.setStyleSheet("background: rgba(255, 255, 255, 0.1);") - self.main_layout.addWidget(separator) + self.grid_widget = QWidget() + self.grid_layout = QGridLayout(self.grid_widget) + self.grid_layout.setSpacing(10) + self.grid_layout.setContentsMargins(0, 0, 0, 0) - # === 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) + scroll.setWidget(self.grid_widget) + layout.addWidget(scroll) - # 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() + self._refresh_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.""" + def _refresh_plugins(self): + """Refresh plugin grid.""" # Clear existing - for btn in self.pinned_buttons.values(): - btn.deleteLater() - self.pinned_buttons.clear() + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() 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() + # 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)) - 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) + # 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) - # Drawer style: subtle frosted glass - self.drawer.setStyleSheet(""" + 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(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; + background: rgba(255, 255, 255, 0.05); 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 { + QFrame:hover { background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); } """) - btn.clicked.connect(lambda: self._on_drawer_item_clicked(plugin_id)) - return btn + 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 pinned plugin click.""" - print(f"[Taskbar] Plugin clicked: {plugin_id}") - self.widget_requested.emit(plugin_id) - self._pulse_animation() + """Handle plugin click.""" + self.plugin_launched.emit(plugin_id) + self.hide() - 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.""" + def _show_context_menu(self, pos, plugin_id: str): + """Show context menu.""" menu = QMenu(self) menu.setStyleSheet(""" QMenu { - background: rgba(20, 31, 35, 0.95); + background: rgba(40, 40, 40, 0.95); color: white; - border: 1px solid rgba(255, 140, 66, 0.15); + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 8px; } @@ -550,96 +407,254 @@ class WindowsTaskbar(QFrame): border-radius: 4px; } QMenu::item:selected { - background: rgba(255, 140, 66, 0.2); + background: rgba(255, 255, 255, 0.1); } """) - # Toggle search - search_action = menu.addAction("☑ Search" if self.config.show_search else "☐ Search") - search_action.triggered.connect(self._toggle_search) + pin_action = menu.addAction("📌 Pin to Taskbar") + pin_action.triggered.connect(lambda: self.plugin_pin_requested.emit(plugin_id)) - # 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)) + menu.exec(self.mapToGlobal(pos)) - 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 _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.""" - 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() + plugin_requested = pyqtSignal(str) + search_requested = pyqtSignal(str) + settings_requested = pyqtSignal() - 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 __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 _show_settings(self): - """Show settings dialog.""" - dialog = TaskbarSettingsDialog(self.config, self) - if dialog.exec(): - self.config = dialog.get_config() + 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() - self._apply_config() - self._refresh_pinned_plugins() + + 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.""" - # Set auto-hide timer - self.hide_timer.setInterval(self.config.auto_hide_delay) + screen = QApplication.primaryScreen().geometry() - # Apply opacity - self._apply_opacity() + if self.config.position == "bottom": + self.move((screen.width() - 700) // 2, screen.height() - 70) + else: + self.move((screen.width() - 700) // 2, 20) - # 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) + self.setFixedWidth(700) def _load_config(self) -> ActivityBarConfig: """Load configuration.""" @@ -657,128 +672,44 @@ class WindowsTaskbar(QFrame): 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 enterEvent(self, event): + """Mouse entered.""" + self.hide_timer.stop() + super().enterEvent(event) - 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 leaveEvent(self, event): + """Mouse left.""" + if self.config.auto_hide: + self.hide_timer.start(self.config.auto_hide_delay) + super().leaveEvent(event) - 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 - ) + 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 -_taskbar_instance: Optional[WindowsTaskbar] = None +_activity_bar_instance = 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 +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 diff --git a/core/activity_bar_enhanced.py b/core/activity_bar_enhanced.py deleted file mode 100644 index 55b463a..0000000 --- a/core/activity_bar_enhanced.py +++ /dev/null @@ -1,715 +0,0 @@ -""" -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