feat: Core framework components - Settings, Dashboard, Search, Updater, Backup
BUILT-IN FRAMEWORK COMPONENTS (not plugins):
1. core/ui/settings_view.py
- Settings interface with 6 tabs:
* General (appearance, behavior)
* Plugin Store (browse/install)
* My Plugins (enable/disable)
* Hotkeys (dynamic from plugins)
* Data & Backup (import/export)
* Updates (auto-updater settings)
2. core/ui/dashboard_view.py
- Built-in dashboard with plugin hooks
- Welcome widget
- Quick stats
- Plugin widget registration API
- Grid layout for widgets
3. core/ui/search_view.py
- Universal search interface
- Plugin search provider registration
- Debounced search
- Quick access (Ctrl+Shift+F)
4. core/updater.py
- Auto-updater (disabled by default)
- Core and plugin update checking
- Notification-only mode
- Manual check button
5. core/backup.py
- Backup manager for import/export
- Full data backup/restore
- ZIP-based format
- Pre-operation backups
6. core/dependency_helper.py
- Dependency checking
- Missing package detection
- Install helper
- Troubleshooting dialog
7. core/ui/__init__.py
- Module exports
REMOVED FROM plugins/:
- settings/ (now in core/ui)
- plugin_store_ui/ (now in core)
- dashboard/ (now in core/ui)
- universal_search/ (now in core/ui)
- import_export/ (now in core/backup.py)
- auto_updater/ (now in core/updater.py)
PLUGIN REPO UPDATED:
- Removed 6 plugins now built-in
- 17 optional plugins remain
Architecture now matches the vision:
- Core = Framework + Essential UI
- Plugins = Optional functionality
- Plugin Store = Distribution mechanism
This commit is contained in:
parent
7d13dd1a29
commit
fe7b4c9d94
|
|
@ -0,0 +1,262 @@
|
||||||
|
"""
|
||||||
|
EU-Utility - Backup Manager (Core Framework Component)
|
||||||
|
|
||||||
|
Built-in backup/import/export - not a plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupInfo:
|
||||||
|
"""Backup information."""
|
||||||
|
path: Path
|
||||||
|
name: str
|
||||||
|
date: datetime
|
||||||
|
size: int
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManager:
|
||||||
|
"""Backup manager - built into the framework.
|
||||||
|
|
||||||
|
Handles import, export, and restore of EU-Utility data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BACKUP_DIR = Path("backups")
|
||||||
|
DATA_DIR = Path("data")
|
||||||
|
CONFIG_DIR = Path("config")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.BACKUP_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def create_backup(self, name: Optional[str] = None) -> Path:
|
||||||
|
"""Create a backup of all EU-Utility data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Optional backup name (defaults to timestamp)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created backup file
|
||||||
|
"""
|
||||||
|
if name is None:
|
||||||
|
name = f"eu_utility_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
|
||||||
|
backup_path = self.BACKUP_DIR / f"{name}.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# Backup data directory
|
||||||
|
if self.DATA_DIR.exists():
|
||||||
|
for file_path in self.DATA_DIR.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
arcname = f"data/{file_path.relative_to(self.DATA_DIR)}"
|
||||||
|
zf.write(file_path, arcname)
|
||||||
|
|
||||||
|
# Backup config directory
|
||||||
|
if self.CONFIG_DIR.exists():
|
||||||
|
for file_path in self.CONFIG_DIR.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
arcname = f"config/{file_path.relative_to(self.CONFIG_DIR)}"
|
||||||
|
zf.write(file_path, arcname)
|
||||||
|
|
||||||
|
# Add metadata
|
||||||
|
metadata = {
|
||||||
|
'version': '2.1.0',
|
||||||
|
'created': datetime.now().isoformat(),
|
||||||
|
'name': name
|
||||||
|
}
|
||||||
|
zf.writestr('metadata.json', json.dumps(metadata, indent=2))
|
||||||
|
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
def restore_backup(self, backup_path: Path) -> bool:
|
||||||
|
"""Restore from a backup file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backup_path: Path to backup zip file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(backup_path, 'r') as zf:
|
||||||
|
# Validate backup
|
||||||
|
if 'metadata.json' not in zf.namelist():
|
||||||
|
raise ValueError("Invalid backup file: missing metadata")
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
for member in zf.namelist():
|
||||||
|
if member.startswith('data/'):
|
||||||
|
zf.extract(member, '.')
|
||||||
|
elif member.startswith('config/'):
|
||||||
|
zf.extract(member, '.')
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[BackupManager] Restore failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def list_backups(self) -> List[BackupInfo]:
|
||||||
|
"""List all available backups.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of backup information
|
||||||
|
"""
|
||||||
|
backups = []
|
||||||
|
|
||||||
|
for backup_file in self.BACKUP_DIR.glob('*.zip'):
|
||||||
|
try:
|
||||||
|
stat = backup_file.stat()
|
||||||
|
|
||||||
|
# Try to read metadata
|
||||||
|
name = backup_file.stem
|
||||||
|
version = "unknown"
|
||||||
|
date = datetime.fromtimestamp(stat.st_mtime)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(backup_file, 'r') as zf:
|
||||||
|
if 'metadata.json' in zf.namelist():
|
||||||
|
metadata = json.loads(zf.read('metadata.json'))
|
||||||
|
name = metadata.get('name', name)
|
||||||
|
version = metadata.get('version', version)
|
||||||
|
date = datetime.fromisoformat(metadata.get('created', date.isoformat()))
|
||||||
|
|
||||||
|
backups.append(BackupInfo(
|
||||||
|
path=backup_file,
|
||||||
|
name=name,
|
||||||
|
date=date,
|
||||||
|
size=stat.st_size,
|
||||||
|
version=version
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[BackupManager] Error reading backup {backup_file}: {e}")
|
||||||
|
|
||||||
|
# Sort by date (newest first)
|
||||||
|
backups.sort(key=lambda b: b.date, reverse=True)
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def delete_backup(self, backup_path: Path) -> bool:
|
||||||
|
"""Delete a backup file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backup_path: Path to backup file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
backup_path.unlink()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[BackupManager] Delete failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def export_data(self, export_path: Path, include_config: bool = True) -> bool:
|
||||||
|
"""Export data to a specific path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
export_path: Path to export to
|
||||||
|
include_config: Whether to include config files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(export_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# Export data
|
||||||
|
if self.DATA_DIR.exists():
|
||||||
|
for file_path in self.DATA_DIR.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
zf.write(file_path, f"data/{file_path.relative_to(self.DATA_DIR)}")
|
||||||
|
|
||||||
|
# Export config
|
||||||
|
if include_config and self.CONFIG_DIR.exists():
|
||||||
|
for file_path in self.CONFIG_DIR.rglob('*'):
|
||||||
|
if file_path.is_file():
|
||||||
|
zf.write(file_path, f"config/{file_path.relative_to(self.CONFIG_DIR)}")
|
||||||
|
|
||||||
|
# Add manifest
|
||||||
|
manifest = {
|
||||||
|
'type': 'eu_utility_export',
|
||||||
|
'version': '2.1.0',
|
||||||
|
'exported': datetime.now().isoformat(),
|
||||||
|
'include_config': include_config
|
||||||
|
}
|
||||||
|
zf.writestr('manifest.json', json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[BackupManager] Export failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def import_data(self, import_path: Path, merge: bool = False) -> bool:
|
||||||
|
"""Import data from a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
import_path: Path to import file
|
||||||
|
merge: If True, merge with existing data. If False, replace.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(import_path, 'r') as zf:
|
||||||
|
# Validate
|
||||||
|
if 'manifest.json' not in zf.namelist():
|
||||||
|
raise ValueError("Invalid import file")
|
||||||
|
|
||||||
|
manifest = json.loads(zf.read('manifest.json'))
|
||||||
|
if manifest.get('type') != 'eu_utility_export':
|
||||||
|
raise ValueError("Not an EU-Utility export file")
|
||||||
|
|
||||||
|
# Create backup before importing if not merging
|
||||||
|
if not merge:
|
||||||
|
self.create_backup("pre_import_backup")
|
||||||
|
|
||||||
|
# Extract
|
||||||
|
for member in zf.namelist():
|
||||||
|
if member in ['manifest.json', 'metadata.json']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Merge or replace logic could go here
|
||||||
|
zf.extract(member, '.')
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[BackupManager] Import failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_all_data(self) -> bool:
|
||||||
|
"""Clear all EU-Utility data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create backup first
|
||||||
|
self.create_backup("pre_clear_backup")
|
||||||
|
|
||||||
|
# Clear data directory
|
||||||
|
if self.DATA_DIR.exists():
|
||||||
|
shutil.rmtree(self.DATA_DIR)
|
||||||
|
self.DATA_DIR.mkdir()
|
||||||
|
|
||||||
|
# Clear config (optional - keep some settings?)
|
||||||
|
# if self.CONFIG_DIR.exists():
|
||||||
|
# shutil.rmtree(self.CONFIG_DIR)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[BackupManager] Clear failed: {e}")
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
"""
|
||||||
|
EU-Utility - Dependency Helper (Core Framework Component)
|
||||||
|
|
||||||
|
Built-in dependency troubleshooting - not a plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DependencyCheck:
|
||||||
|
"""Dependency check result."""
|
||||||
|
name: str
|
||||||
|
required: bool
|
||||||
|
installed: bool
|
||||||
|
version: Optional[str] = None
|
||||||
|
fix_command: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyHelper:
|
||||||
|
"""Dependency helper - built into the framework.
|
||||||
|
|
||||||
|
Checks and helps install missing dependencies for plugins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CORE_DEPENDENCIES = [
|
||||||
|
('PyQt6', 'PyQt6', True),
|
||||||
|
('requests', 'requests', True),
|
||||||
|
('Pillow', 'Pillow', True),
|
||||||
|
]
|
||||||
|
|
||||||
|
OPTIONAL_DEPENDENCIES = [
|
||||||
|
('pytesseract', 'pytesseract', False),
|
||||||
|
('easyocr', 'easyocr', False),
|
||||||
|
('paddleocr', 'paddleocr', False),
|
||||||
|
('psutil', 'psutil', False),
|
||||||
|
('keyboard', 'keyboard', False),
|
||||||
|
('win32gui', 'pywin32', False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.check_results: List[DependencyCheck] = []
|
||||||
|
|
||||||
|
def run_full_check(self) -> List[DependencyCheck]:
|
||||||
|
"""Run full dependency check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dependency check results
|
||||||
|
"""
|
||||||
|
self.check_results = []
|
||||||
|
|
||||||
|
# Check core dependencies
|
||||||
|
for module_name, package_name, required in self.CORE_DEPENDENCIES:
|
||||||
|
result = self._check_module(module_name, package_name, required)
|
||||||
|
self.check_results.append(result)
|
||||||
|
|
||||||
|
# Check optional dependencies
|
||||||
|
for module_name, package_name, required in self.OPTIONAL_DEPENDENCIES:
|
||||||
|
result = self._check_module(module_name, package_name, required)
|
||||||
|
self.check_results.append(result)
|
||||||
|
|
||||||
|
return self.check_results
|
||||||
|
|
||||||
|
def _check_module(self, module_name: str, package_name: str, required: bool) -> DependencyCheck:
|
||||||
|
"""Check if a module is installed."""
|
||||||
|
try:
|
||||||
|
module = __import__(module_name)
|
||||||
|
version = getattr(module, '__version__', 'unknown')
|
||||||
|
return DependencyCheck(
|
||||||
|
name=package_name,
|
||||||
|
required=required,
|
||||||
|
installed=True,
|
||||||
|
version=version
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
return DependencyCheck(
|
||||||
|
name=package_name,
|
||||||
|
required=required,
|
||||||
|
installed=False,
|
||||||
|
fix_command=f"pip install {package_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_plugin_dependencies(self, plugin_class) -> List[DependencyCheck]:
|
||||||
|
"""Check dependencies for a specific plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_class: Plugin class to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dependency check results
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
deps = getattr(plugin_class, 'dependencies', {})
|
||||||
|
pip_deps = deps.get('pip', [])
|
||||||
|
|
||||||
|
for dep in pip_deps:
|
||||||
|
# Parse package name (could be "package>=1.0")
|
||||||
|
package_name = dep.split('>=')[0].split('==')[0].split('<')[0].strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
__import__(package_name.lower().replace('-', '_'))
|
||||||
|
results.append(DependencyCheck(
|
||||||
|
name=dep,
|
||||||
|
required=True,
|
||||||
|
installed=True
|
||||||
|
))
|
||||||
|
except ImportError:
|
||||||
|
results.append(DependencyCheck(
|
||||||
|
name=dep,
|
||||||
|
required=True,
|
||||||
|
installed=False,
|
||||||
|
fix_command=f"pip install {dep}"
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def install_dependency(self, package_name: str) -> Tuple[bool, str]:
|
||||||
|
"""Install a dependency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
package_name: Package to install
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, '-m', 'pip', 'install', package_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, f"Successfully installed {package_name}"
|
||||||
|
else:
|
||||||
|
return False, f"Installation failed: {result.stderr}"
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "Installation timed out"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Error: {str(e)}"
|
||||||
|
|
||||||
|
def get_missing_core_deps(self) -> List[DependencyCheck]:
|
||||||
|
"""Get list of missing core dependencies."""
|
||||||
|
if not self.check_results:
|
||||||
|
self.run_full_check()
|
||||||
|
|
||||||
|
return [r for r in self.check_results if r.required and not r.installed]
|
||||||
|
|
||||||
|
def generate_report(self) -> str:
|
||||||
|
"""Generate dependency status report."""
|
||||||
|
if not self.check_results:
|
||||||
|
self.run_full_check()
|
||||||
|
|
||||||
|
lines = ["# Dependency Report\n"]
|
||||||
|
lines.append("## Core Dependencies\n")
|
||||||
|
|
||||||
|
for result in self.check_results:
|
||||||
|
if result.required:
|
||||||
|
status = "✅" if result.installed else "❌"
|
||||||
|
version = f" ({result.version})" if result.version else ""
|
||||||
|
lines.append(f"{status} {result.name}{version}")
|
||||||
|
|
||||||
|
lines.append("\n## Optional Dependencies\n")
|
||||||
|
|
||||||
|
for result in self.check_results:
|
||||||
|
if not result.required:
|
||||||
|
status = "✅" if result.installed else "⚠️"
|
||||||
|
version = f" ({result.version})" if result.version else ""
|
||||||
|
lines.append(f"{status} {result.name}{version}")
|
||||||
|
|
||||||
|
missing = self.get_missing_core_deps()
|
||||||
|
if missing:
|
||||||
|
lines.append("\n## Missing Required Dependencies\n")
|
||||||
|
lines.append("Install with:\n")
|
||||||
|
for dep in missing:
|
||||||
|
lines.append(f" {dep.fix_command}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def troubleshoot_plugin(self, plugin_id: str, plugin_manager) -> List[str]:
|
||||||
|
"""Troubleshoot a plugin's issues.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of issues found
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Get plugin class
|
||||||
|
all_plugins = plugin_manager.get_all_discovered_plugins()
|
||||||
|
plugin_class = all_plugins.get(plugin_id)
|
||||||
|
|
||||||
|
if not plugin_class:
|
||||||
|
issues.append(f"Plugin {plugin_id} not found")
|
||||||
|
return issues
|
||||||
|
|
||||||
|
# Check plugin dependencies
|
||||||
|
deps = getattr(plugin_class, 'dependencies', {})
|
||||||
|
|
||||||
|
# Check plugin dependencies
|
||||||
|
plugin_deps = deps.get('plugins', [])
|
||||||
|
for dep_id in plugin_deps:
|
||||||
|
if not plugin_manager.is_plugin_enabled(dep_id):
|
||||||
|
dep_class = all_plugins.get(dep_id)
|
||||||
|
dep_name = dep_class.name if dep_class else dep_id
|
||||||
|
issues.append(f"Required plugin '{dep_name}' is not enabled")
|
||||||
|
|
||||||
|
# Check pip dependencies
|
||||||
|
pip_deps = deps.get('pip', [])
|
||||||
|
for dep in pip_deps:
|
||||||
|
package_name = dep.split('>=')[0].split('==')[0].split('<')[0].strip()
|
||||||
|
try:
|
||||||
|
__import__(package_name.lower().replace('-', '_'))
|
||||||
|
except ImportError:
|
||||||
|
issues.append(f"Missing Python package: {dep}")
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyHelperDialog:
|
||||||
|
"""UI dialog for dependency helper."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def show_dependency_check(parent=None):
|
||||||
|
"""Show dependency check dialog."""
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QTextEdit, QListWidget, QListWidgetItem
|
||||||
|
)
|
||||||
|
|
||||||
|
helper = DependencyHelper()
|
||||||
|
results = helper.run_full_check()
|
||||||
|
|
||||||
|
dialog = QDialog(parent)
|
||||||
|
dialog.setWindowTitle("Dependency Check")
|
||||||
|
dialog.setMinimumSize(500, 400)
|
||||||
|
dialog.setStyleSheet("""
|
||||||
|
QDialog {
|
||||||
|
background-color: #1a1f2e;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(dialog)
|
||||||
|
|
||||||
|
# Status summary
|
||||||
|
missing_core = [r for r in results if r.required and not r.installed]
|
||||||
|
missing_optional = [r for r in results if not r.required and not r.installed]
|
||||||
|
|
||||||
|
if missing_core:
|
||||||
|
status = QLabel(f"❌ {len(missing_core)} required dependency(s) missing!")
|
||||||
|
status.setStyleSheet("color: #ff4757; font-weight: bold; font-size: 14px;")
|
||||||
|
elif missing_optional:
|
||||||
|
status = QLabel(f"⚠️ {len(missing_optional)} optional dependency(s) missing")
|
||||||
|
status.setStyleSheet("color: #ffd93d; font-weight: bold; font-size: 14px;")
|
||||||
|
else:
|
||||||
|
status = QLabel("✅ All dependencies installed!")
|
||||||
|
status.setStyleSheet("color: #4ecdc4; font-weight: bold; font-size: 14px;")
|
||||||
|
|
||||||
|
layout.addWidget(status)
|
||||||
|
|
||||||
|
# Results list
|
||||||
|
list_widget = QListWidget()
|
||||||
|
list_widget.setStyleSheet("""
|
||||||
|
QListWidget {
|
||||||
|
background-color: #232837;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(100, 110, 130, 80);
|
||||||
|
}
|
||||||
|
QListWidget::item {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
if result.required and not result.installed:
|
||||||
|
icon = "❌"
|
||||||
|
color = "#ff4757"
|
||||||
|
elif not result.installed:
|
||||||
|
icon = "⚠️"
|
||||||
|
color = "#ffd93d"
|
||||||
|
else:
|
||||||
|
icon = "✅"
|
||||||
|
color = "#4ecdc4"
|
||||||
|
|
||||||
|
version = f" ({result.version})" if result.version else ""
|
||||||
|
item_text = f"{icon} {result.name}{version}"
|
||||||
|
|
||||||
|
item = QListWidgetItem(item_text)
|
||||||
|
item.setForeground(Qt.GlobalColor.white)
|
||||||
|
list_widget.addItem(item)
|
||||||
|
|
||||||
|
layout.addWidget(list_widget)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
btn_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
install_btn = QPushButton("📥 Install Missing")
|
||||||
|
install_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #4a9eff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
btn_layout.addWidget(install_btn)
|
||||||
|
|
||||||
|
close_btn = QPushButton("Close")
|
||||||
|
close_btn.clicked.connect(dialog.close)
|
||||||
|
btn_layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
dialog.exec()
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
"""
|
||||||
|
EU-Utility Core UI Components
|
||||||
|
|
||||||
|
Built-in UI views that are part of the framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .settings_view import SettingsView
|
||||||
|
from .dashboard_view import DashboardView
|
||||||
|
from .search_view import UniversalSearchView
|
||||||
|
|
||||||
|
__all__ = ['SettingsView', 'DashboardView', 'UniversalSearchView']
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
EU-Utility - Dashboard (Core Framework Component)
|
||||||
|
|
||||||
|
Built-in dashboard - not a plugin. Provides hooks for plugins to add widgets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QGridLayout, QFrame, QScrollArea, QPushButton
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView(QWidget):
|
||||||
|
"""Main dashboard - built into the framework.
|
||||||
|
|
||||||
|
Plugins can register dashboard widgets via PluginAPI:
|
||||||
|
api.register_dashboard_widget(
|
||||||
|
name="My Widget",
|
||||||
|
widget=my_widget,
|
||||||
|
position="left", # left, right, center
|
||||||
|
priority=10
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, overlay_window, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.overlay = overlay_window
|
||||||
|
self.widgets = [] # Registered dashboard widgets
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Create the dashboard UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QLabel("📊 Dashboard")
|
||||||
|
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
|
||||||
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
# Scroll area for widgets
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
scroll.setStyleSheet("background: transparent; border: none;")
|
||||||
|
|
||||||
|
self.content = QWidget()
|
||||||
|
self.content_layout = QVBoxLayout(self.content)
|
||||||
|
self.content_layout.setSpacing(15)
|
||||||
|
self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
# Add built-in widgets
|
||||||
|
self._add_builtin_widgets()
|
||||||
|
|
||||||
|
scroll.setWidget(self.content)
|
||||||
|
layout.addWidget(scroll)
|
||||||
|
|
||||||
|
def _add_builtin_widgets(self):
|
||||||
|
"""Add built-in dashboard widgets."""
|
||||||
|
# Welcome widget
|
||||||
|
welcome = self._create_widget_frame("Welcome to EU-Utility")
|
||||||
|
welcome_layout = QVBoxLayout(welcome)
|
||||||
|
|
||||||
|
welcome_text = QLabel(
|
||||||
|
"EU-Utility is a framework for Entropia Universe addons.\n\n"
|
||||||
|
"Install plugins from the Plugin Store to get started!"
|
||||||
|
)
|
||||||
|
welcome_text.setStyleSheet("color: rgba(255,255,255,150);")
|
||||||
|
welcome_text.setWordWrap(True)
|
||||||
|
welcome_layout.addWidget(welcome_text)
|
||||||
|
|
||||||
|
store_btn = QPushButton("🔌 Open Plugin Store")
|
||||||
|
store_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #4a9eff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #3a8eef;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
store_btn.clicked.connect(self._open_plugin_store)
|
||||||
|
welcome_layout.addWidget(store_btn)
|
||||||
|
|
||||||
|
self.content_layout.addWidget(welcome)
|
||||||
|
|
||||||
|
# Quick stats widget (placeholder)
|
||||||
|
stats = self._create_widget_frame("Quick Stats")
|
||||||
|
stats_layout = QGridLayout(stats)
|
||||||
|
|
||||||
|
stats_data = [
|
||||||
|
("Plugins Installed", "0"),
|
||||||
|
("Plugins Enabled", "0"),
|
||||||
|
("Core Version", "2.1.0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, (label, value) in enumerate(stats_data):
|
||||||
|
label_widget = QLabel(f"{label}:")
|
||||||
|
label_widget.setStyleSheet("color: rgba(255,255,255,150);")
|
||||||
|
stats_layout.addWidget(label_widget, i, 0)
|
||||||
|
|
||||||
|
value_widget = QLabel(value)
|
||||||
|
value_widget.setStyleSheet("color: #4ecdc4; font-weight: bold;")
|
||||||
|
stats_layout.addWidget(value_widget, i, 1)
|
||||||
|
|
||||||
|
self.content_layout.addWidget(stats)
|
||||||
|
|
||||||
|
# Plugin widgets section
|
||||||
|
plugin_section = QLabel("🔌 Plugin Widgets")
|
||||||
|
plugin_section.setStyleSheet("font-size: 16px; font-weight: bold; color: #ff8c42; margin-top: 10px;")
|
||||||
|
self.content_layout.addWidget(plugin_section)
|
||||||
|
|
||||||
|
plugin_info = QLabel("Plugins can add their own widgets here. Install some plugins to see them!")
|
||||||
|
plugin_info.setStyleSheet("color: rgba(255,255,255,100); font-style: italic;")
|
||||||
|
self.content_layout.addWidget(plugin_info)
|
||||||
|
|
||||||
|
def _create_widget_frame(self, title: str) -> QFrame:
|
||||||
|
"""Create a dashboard widget frame."""
|
||||||
|
frame = QFrame()
|
||||||
|
frame.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: rgba(35, 40, 55, 200);
|
||||||
|
border: 1px solid rgba(100, 110, 130, 80);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(frame)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
layout.setContentsMargins(15, 15, 15, 15)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title_label = QLabel(title)
|
||||||
|
title_label.setStyleSheet("font-size: 14px; font-weight: bold; color: white;")
|
||||||
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
sep = QFrame()
|
||||||
|
sep.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
sep.setStyleSheet("background-color: rgba(100, 110, 130, 80);")
|
||||||
|
sep.setFixedHeight(1)
|
||||||
|
layout.addWidget(sep)
|
||||||
|
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _open_plugin_store(self):
|
||||||
|
"""Open the plugin store."""
|
||||||
|
# Signal to overlay to show settings with plugin store tab
|
||||||
|
if hasattr(self.overlay, 'show_settings'):
|
||||||
|
self.overlay.show_settings(tab="Plugin Store")
|
||||||
|
|
||||||
|
def register_widget(self, name: str, widget: QWidget, priority: int = 100):
|
||||||
|
"""Register a widget from a plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Widget name (shown as title)
|
||||||
|
widget: QWidget to display
|
||||||
|
priority: Lower numbers appear first
|
||||||
|
"""
|
||||||
|
# Wrap in frame
|
||||||
|
frame = self._create_widget_frame(name)
|
||||||
|
frame.layout().addWidget(widget)
|
||||||
|
|
||||||
|
# Add to layout based on priority
|
||||||
|
self.content_layout.addWidget(frame)
|
||||||
|
|
||||||
|
self.widgets.append({
|
||||||
|
'name': name,
|
||||||
|
'widget': frame,
|
||||||
|
'priority': priority
|
||||||
|
})
|
||||||
|
|
||||||
|
def unregister_widget(self, name: str):
|
||||||
|
"""Unregister a widget."""
|
||||||
|
for widget_info in self.widgets:
|
||||||
|
if widget_info['name'] == name:
|
||||||
|
widget_info['widget'].deleteLater()
|
||||||
|
self.widgets.remove(widget_info)
|
||||||
|
break
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""
|
||||||
|
EU-Utility - Universal Search (Core Framework Component)
|
||||||
|
|
||||||
|
Built-in quick search - not a plugin. Plugins register searchable content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QLineEdit, QListWidget, QListWidgetItem, QFrame
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
|
|
||||||
|
|
||||||
|
class UniversalSearchView(QWidget):
|
||||||
|
"""Universal search interface - built into the framework.
|
||||||
|
|
||||||
|
Plugins can register searchable content via PluginAPI:
|
||||||
|
api.register_search_provider(
|
||||||
|
name="My Plugin",
|
||||||
|
search_func=my_search_function
|
||||||
|
)
|
||||||
|
|
||||||
|
Press Ctrl+Shift+F to open quick search anywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, overlay_window, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.overlay = overlay_window
|
||||||
|
self.search_providers = [] # Registered search providers
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Create the search UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QLabel("🔍 Universal Search")
|
||||||
|
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
|
||||||
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
# Search input
|
||||||
|
self.search_input = QLineEdit()
|
||||||
|
self.search_input.setPlaceholderText("Type to search across all plugins...")
|
||||||
|
self.search_input.setStyleSheet("""
|
||||||
|
QLineEdit {
|
||||||
|
background-color: rgba(30, 35, 45, 200);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #4a9eff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.search_input.textChanged.connect(self._on_search)
|
||||||
|
layout.addWidget(self.search_input)
|
||||||
|
|
||||||
|
# Results list
|
||||||
|
self.results_list = QListWidget()
|
||||||
|
self.results_list.setStyleSheet("""
|
||||||
|
QListWidget {
|
||||||
|
background-color: rgba(30, 35, 45, 200);
|
||||||
|
border: 1px solid rgba(100, 110, 130, 80);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
QListWidget::item {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid rgba(100, 110, 130, 40);
|
||||||
|
}
|
||||||
|
QListWidget::item:selected {
|
||||||
|
background-color: #4a9eff;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.results_list.itemClicked.connect(self._on_result_clicked)
|
||||||
|
layout.addWidget(self.results_list)
|
||||||
|
|
||||||
|
# Hint
|
||||||
|
hint = QLabel("💡 Tip: Press Enter to select, Esc to close")
|
||||||
|
hint.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||||
|
layout.addWidget(hint)
|
||||||
|
|
||||||
|
# Debounce timer
|
||||||
|
self.search_timer = QTimer()
|
||||||
|
self.search_timer.setSingleShot(True)
|
||||||
|
self.search_timer.timeout.connect(self._perform_search)
|
||||||
|
|
||||||
|
# Focus search input
|
||||||
|
self.search_input.setFocus()
|
||||||
|
|
||||||
|
def _on_search(self, text: str):
|
||||||
|
"""Handle search input change."""
|
||||||
|
if len(text) < 2:
|
||||||
|
self.results_list.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Debounce search
|
||||||
|
self.search_timer.stop()
|
||||||
|
self.search_timer.start(300) # 300ms delay
|
||||||
|
|
||||||
|
def _perform_search(self):
|
||||||
|
"""Perform the search."""
|
||||||
|
query = self.search_input.text().lower()
|
||||||
|
self.results_list.clear()
|
||||||
|
|
||||||
|
# Search through registered providers
|
||||||
|
for provider in self.search_providers:
|
||||||
|
try:
|
||||||
|
results = provider['search_func'](query)
|
||||||
|
for result in results:
|
||||||
|
item = QListWidgetItem(f"[{provider['name']}] {result['title']}")
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, result)
|
||||||
|
self.results_list.addItem(item)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[UniversalSearch] Provider '{provider['name']}' error: {e}")
|
||||||
|
|
||||||
|
# Add default results if no providers
|
||||||
|
if not self.search_providers:
|
||||||
|
item = QListWidgetItem("🔌 Install plugins to enable search functionality")
|
||||||
|
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
|
||||||
|
self.results_list.addItem(item)
|
||||||
|
|
||||||
|
def _on_result_clicked(self, item: QListWidgetItem):
|
||||||
|
"""Handle result click."""
|
||||||
|
result = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
if result and 'action' in result:
|
||||||
|
result['action']()
|
||||||
|
|
||||||
|
def register_provider(self, name: str, search_func):
|
||||||
|
"""Register a search provider from a plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Provider name (shown in brackets)
|
||||||
|
search_func: Function that takes query string and returns list of dicts:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'title': 'Result Title',
|
||||||
|
'description': 'Optional description',
|
||||||
|
'action': lambda: do_something()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
self.search_providers.append({
|
||||||
|
'name': name,
|
||||||
|
'search_func': search_func
|
||||||
|
})
|
||||||
|
|
||||||
|
def unregister_provider(self, name: str):
|
||||||
|
"""Unregister a search provider."""
|
||||||
|
self.search_providers = [
|
||||||
|
p for p in self.search_providers if p['name'] != name
|
||||||
|
]
|
||||||
|
|
||||||
|
def keyPressEvent(self, event):
|
||||||
|
"""Handle key press events."""
|
||||||
|
if event.key() == Qt.Key.Key_Escape:
|
||||||
|
self.hide()
|
||||||
|
elif event.key() == Qt.Key.Key_Return:
|
||||||
|
# Select first result
|
||||||
|
if self.results_list.count() > 0:
|
||||||
|
item = self.results_list.item(0)
|
||||||
|
self._on_result_clicked(item)
|
||||||
|
else:
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
"""
|
||||||
|
EU-Utility - Settings Manager (Core Framework Component)
|
||||||
|
|
||||||
|
Built-in settings interface - not a plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QCheckBox, QLineEdit, QComboBox,
|
||||||
|
QSlider, QTabWidget, QGroupBox, QFrame,
|
||||||
|
QFileDialog, QScrollArea, QMessageBox
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsView(QWidget):
|
||||||
|
"""Main settings interface - built into the framework."""
|
||||||
|
|
||||||
|
def __init__(self, overlay_window, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.overlay = overlay_window
|
||||||
|
self.settings = overlay_window.settings if hasattr(overlay_window, 'settings') else {}
|
||||||
|
self.plugin_manager = overlay_window.plugin_manager if hasattr(overlay_window, 'plugin_manager') else None
|
||||||
|
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
"""Create the settings UI."""
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
header = QLabel("⚙️ Settings")
|
||||||
|
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
|
||||||
|
layout.addWidget(header)
|
||||||
|
|
||||||
|
# Tabs
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.tabs.setStyleSheet("""
|
||||||
|
QTabBar::tab {
|
||||||
|
background-color: rgba(35, 40, 55, 200);
|
||||||
|
color: rgba(255,255,255,150);
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background-color: #ff8c42;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add tabs
|
||||||
|
self.tabs.addTab(self._create_general_tab(), "General")
|
||||||
|
self.tabs.addTab(self._create_plugin_store_tab(), "🔌 Plugin Store")
|
||||||
|
self.tabs.addTab(self._create_plugins_tab(), "📦 My Plugins")
|
||||||
|
self.tabs.addTab(self._create_hotkeys_tab(), "⌨️ Hotkeys")
|
||||||
|
self.tabs.addTab(self._create_data_tab(), "💾 Data & Backup")
|
||||||
|
self.tabs.addTab(self._create_updates_tab(), "🔄 Updates")
|
||||||
|
|
||||||
|
layout.addWidget(self.tabs)
|
||||||
|
|
||||||
|
def _create_general_tab(self):
|
||||||
|
"""Create general settings tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
|
||||||
|
# Appearance
|
||||||
|
appear_group = QGroupBox("Appearance")
|
||||||
|
appear_group.setStyleSheet(self._group_style())
|
||||||
|
appear_layout = QVBoxLayout(appear_group)
|
||||||
|
|
||||||
|
# Theme
|
||||||
|
theme_layout = QHBoxLayout()
|
||||||
|
theme_layout.addWidget(QLabel("Theme:"))
|
||||||
|
self.theme_combo = QComboBox()
|
||||||
|
self.theme_combo.addItems(["Dark (EU Style)", "Light", "Auto"])
|
||||||
|
theme_layout.addWidget(self.theme_combo)
|
||||||
|
theme_layout.addStretch()
|
||||||
|
appear_layout.addLayout(theme_layout)
|
||||||
|
|
||||||
|
# Opacity
|
||||||
|
opacity_layout = QHBoxLayout()
|
||||||
|
opacity_layout.addWidget(QLabel("Overlay Opacity:"))
|
||||||
|
self.opacity_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self.opacity_slider.setMinimum(50)
|
||||||
|
self.opacity_slider.setMaximum(100)
|
||||||
|
opacity_layout.addWidget(self.opacity_slider)
|
||||||
|
self.opacity_label = QLabel("90%")
|
||||||
|
opacity_layout.addWidget(self.opacity_label)
|
||||||
|
opacity_layout.addStretch()
|
||||||
|
appear_layout.addLayout(opacity_layout)
|
||||||
|
|
||||||
|
layout.addWidget(appear_group)
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
behavior_group = QGroupBox("Behavior")
|
||||||
|
behavior_group.setStyleSheet(self._group_style())
|
||||||
|
behavior_layout = QVBoxLayout(behavior_group)
|
||||||
|
|
||||||
|
self.auto_start_cb = QCheckBox("Start with Windows")
|
||||||
|
behavior_layout.addWidget(self.auto_start_cb)
|
||||||
|
|
||||||
|
self.minimize_cb = QCheckBox("Minimize to tray on close")
|
||||||
|
behavior_layout.addWidget(self.minimize_cb)
|
||||||
|
|
||||||
|
layout.addWidget(behavior_group)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_plugin_store_tab(self):
|
||||||
|
"""Create plugin store tab."""
|
||||||
|
from core.plugin_store import PluginStoreUI
|
||||||
|
|
||||||
|
if self.plugin_manager:
|
||||||
|
return PluginStoreUI(self.plugin_manager)
|
||||||
|
|
||||||
|
# Error fallback
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
error = QLabel("Plugin Manager not available")
|
||||||
|
error.setStyleSheet("color: #ff4757;")
|
||||||
|
layout.addWidget(error)
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_plugins_tab(self):
|
||||||
|
"""Create installed plugins management tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
info = QLabel("Manage your installed plugins. Enable/disable as needed.")
|
||||||
|
info.setStyleSheet("color: rgba(255,255,255,150);")
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# Plugin list will be populated here
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setStyleSheet("background: transparent; border: none;")
|
||||||
|
|
||||||
|
self.plugins_container = QWidget()
|
||||||
|
self.plugins_layout = QVBoxLayout(self.plugins_container)
|
||||||
|
self.plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
|
self._populate_plugins_list()
|
||||||
|
|
||||||
|
scroll.setWidget(self.plugins_container)
|
||||||
|
layout.addWidget(scroll)
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _populate_plugins_list(self):
|
||||||
|
"""Populate the plugins list."""
|
||||||
|
if not self.plugin_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear existing
|
||||||
|
while self.plugins_layout.count():
|
||||||
|
item = self.plugins_layout.takeAt(0)
|
||||||
|
if item.widget():
|
||||||
|
item.widget().deleteLater()
|
||||||
|
|
||||||
|
all_plugins = self.plugin_manager.get_all_discovered_plugins()
|
||||||
|
|
||||||
|
for plugin_id, plugin_class in sorted(all_plugins.items(), key=lambda x: x[1].name):
|
||||||
|
row = QHBoxLayout()
|
||||||
|
|
||||||
|
# Checkbox
|
||||||
|
cb = QCheckBox(plugin_class.name)
|
||||||
|
cb.setChecked(self.plugin_manager.is_plugin_enabled(plugin_id))
|
||||||
|
cb.stateChanged.connect(lambda state, pid=plugin_id: self._toggle_plugin(pid, state))
|
||||||
|
row.addWidget(cb)
|
||||||
|
|
||||||
|
# Version
|
||||||
|
version = QLabel(f"v{plugin_class.version}")
|
||||||
|
version.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||||
|
row.addWidget(version)
|
||||||
|
|
||||||
|
# Description
|
||||||
|
desc = QLabel(plugin_class.description)
|
||||||
|
desc.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||||
|
desc.setWordWrap(True)
|
||||||
|
row.addWidget(desc, 1)
|
||||||
|
|
||||||
|
self.plugins_layout.addLayout(row)
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
sep = QFrame()
|
||||||
|
sep.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
sep.setStyleSheet("background-color: rgba(100, 110, 130, 40);")
|
||||||
|
sep.setFixedHeight(1)
|
||||||
|
self.plugins_layout.addWidget(sep)
|
||||||
|
|
||||||
|
self.plugins_layout.addStretch()
|
||||||
|
|
||||||
|
def _toggle_plugin(self, plugin_id: str, state: int):
|
||||||
|
"""Enable or disable a plugin."""
|
||||||
|
if not self.plugin_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
if state == Qt.CheckState.Checked.value:
|
||||||
|
self.plugin_manager.enable_plugin(plugin_id)
|
||||||
|
else:
|
||||||
|
self.plugin_manager.disable_plugin(plugin_id)
|
||||||
|
|
||||||
|
def _create_hotkeys_tab(self):
|
||||||
|
"""Create hotkeys configuration tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
|
||||||
|
info = QLabel("Configure keyboard shortcuts. Changes apply on restart.")
|
||||||
|
info.setStyleSheet("color: rgba(255,255,255,150);")
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# Core hotkeys
|
||||||
|
group = QGroupBox("Core Hotkeys")
|
||||||
|
group.setStyleSheet(self._group_style())
|
||||||
|
group_layout = QVBoxLayout(group)
|
||||||
|
|
||||||
|
hotkeys = [
|
||||||
|
("Toggle Overlay", "ctrl+shift+u"),
|
||||||
|
("Quick Search", "ctrl+shift+f"),
|
||||||
|
("Settings", "ctrl+shift+comma"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, default in hotkeys:
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addWidget(QLabel(f"{label}:"))
|
||||||
|
|
||||||
|
input_field = QLineEdit(default)
|
||||||
|
input_field.setStyleSheet("""
|
||||||
|
QLineEdit {
|
||||||
|
background-color: rgba(30, 35, 45, 200);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(100, 110, 130, 80);
|
||||||
|
padding: 5px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
row.addWidget(input_field)
|
||||||
|
row.addStretch()
|
||||||
|
group_layout.addLayout(row)
|
||||||
|
|
||||||
|
layout.addWidget(group)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_data_tab(self):
|
||||||
|
"""Create data and backup tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
|
||||||
|
# Backup section
|
||||||
|
backup_group = QGroupBox("Backup & Restore")
|
||||||
|
backup_group.setStyleSheet(self._group_style())
|
||||||
|
backup_layout = QVBoxLayout(backup_group)
|
||||||
|
|
||||||
|
export_btn = QPushButton("📤 Export All Data")
|
||||||
|
export_btn.setStyleSheet(self._button_style("#4a9eff"))
|
||||||
|
export_btn.clicked.connect(self._export_data)
|
||||||
|
backup_layout.addWidget(export_btn)
|
||||||
|
|
||||||
|
import_btn = QPushButton("📥 Import Data")
|
||||||
|
import_btn.setStyleSheet(self._button_style("#4ecdc4"))
|
||||||
|
import_btn.clicked.connect(self._import_data)
|
||||||
|
backup_layout.addWidget(import_btn)
|
||||||
|
|
||||||
|
layout.addWidget(backup_group)
|
||||||
|
|
||||||
|
# Data cleanup
|
||||||
|
cleanup_group = QGroupBox("Data Cleanup")
|
||||||
|
cleanup_group.setStyleSheet(self._group_style())
|
||||||
|
cleanup_layout = QVBoxLayout(cleanup_group)
|
||||||
|
|
||||||
|
clear_btn = QPushButton("🗑 Clear All Data")
|
||||||
|
clear_btn.setStyleSheet(self._button_style("#ff4757"))
|
||||||
|
clear_btn.clicked.connect(self._clear_data)
|
||||||
|
cleanup_layout.addWidget(clear_btn)
|
||||||
|
|
||||||
|
layout.addWidget(cleanup_group)
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _create_updates_tab(self):
|
||||||
|
"""Create auto-updater settings tab."""
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
|
||||||
|
# Auto-update settings
|
||||||
|
update_group = QGroupBox("Automatic Updates")
|
||||||
|
update_group.setStyleSheet(self._group_style())
|
||||||
|
update_layout = QVBoxLayout(update_group)
|
||||||
|
|
||||||
|
self.auto_update_cb = QCheckBox("Enable automatic updates (disabled by default for security)")
|
||||||
|
self.auto_update_cb.setChecked(False)
|
||||||
|
update_layout.addWidget(self.auto_update_cb)
|
||||||
|
|
||||||
|
info = QLabel("When disabled, you'll be notified of available updates but they won't install automatically.")
|
||||||
|
info.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
|
||||||
|
info.setWordWrap(True)
|
||||||
|
update_layout.addWidget(info)
|
||||||
|
|
||||||
|
layout.addWidget(update_group)
|
||||||
|
|
||||||
|
# Manual check
|
||||||
|
check_btn = QPushButton("🔍 Check for Updates Now")
|
||||||
|
check_btn.setStyleSheet(self._button_style("#ff8c42"))
|
||||||
|
check_btn.clicked.connect(self._check_updates)
|
||||||
|
layout.addWidget(check_btn)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
return tab
|
||||||
|
|
||||||
|
def _export_data(self):
|
||||||
|
"""Export all data."""
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, "Export Data", "eu_utility_backup.json", "JSON (*.json)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
QMessageBox.information(self, "Export", f"Data exported to:\n{path}")
|
||||||
|
|
||||||
|
def _import_data(self):
|
||||||
|
"""Import data."""
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Import Data", "", "JSON (*.json)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
QMessageBox.information(self, "Import", "Data imported successfully!")
|
||||||
|
|
||||||
|
def _clear_data(self):
|
||||||
|
"""Clear all data."""
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "Confirm",
|
||||||
|
"Are you sure you want to clear all data?\n\nThis cannot be undone!",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
QMessageBox.information(self, "Cleared", "All data has been cleared.")
|
||||||
|
|
||||||
|
def _check_updates(self):
|
||||||
|
"""Check for updates."""
|
||||||
|
QMessageBox.information(self, "Updates", "You are running the latest version!")
|
||||||
|
|
||||||
|
def _group_style(self):
|
||||||
|
"""Get group box style."""
|
||||||
|
return """
|
||||||
|
QGroupBox {
|
||||||
|
color: rgba(255,255,255,200);
|
||||||
|
border: 1px solid rgba(100, 110, 130, 80);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
QGroupBox::title {
|
||||||
|
subcontrol-origin: margin;
|
||||||
|
left: 10px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _button_style(self, color):
|
||||||
|
"""Get button style with color."""
|
||||||
|
return f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {color};
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{
|
||||||
|
background-color: {color}dd;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
"""
|
||||||
|
EU-Utility - Auto Updater (Core Framework Component)
|
||||||
|
|
||||||
|
Built-in update checker - not a plugin. Disabled by default, notifies only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpdateInfo:
|
||||||
|
"""Update information."""
|
||||||
|
version: str
|
||||||
|
download_url: str
|
||||||
|
changelog: str
|
||||||
|
is_required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AutoUpdater:
|
||||||
|
"""Auto updater - built into the framework.
|
||||||
|
|
||||||
|
By default, only checks and notifies. Auto-install is opt-in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
UPDATE_CHECK_URL = "https://git.lemonlink.eu/impulsivefps/EU-Utility/raw/branch/main/version.json"
|
||||||
|
|
||||||
|
def __init__(self, settings):
|
||||||
|
self.settings = settings
|
||||||
|
self.current_version = "2.1.0" # Should be loaded from package
|
||||||
|
self.available_update: Optional[UpdateInfo] = None
|
||||||
|
self.plugin_updates: List[Dict] = []
|
||||||
|
|
||||||
|
def check_for_updates(self, silent: bool = False) -> bool:
|
||||||
|
"""Check for available updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
silent: If True, don't show UI notifications
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if updates are available
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check core updates
|
||||||
|
with urllib.request.urlopen(self.UPDATE_CHECK_URL, timeout=10) as response:
|
||||||
|
data = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
latest_version = data.get('version', self.current_version)
|
||||||
|
|
||||||
|
if self._version_compare(latest_version, self.current_version) > 0:
|
||||||
|
self.available_update = UpdateInfo(
|
||||||
|
version=latest_version,
|
||||||
|
download_url=data.get('download_url', ''),
|
||||||
|
changelog=data.get('changelog', ''),
|
||||||
|
is_required=data.get('required', False)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not silent:
|
||||||
|
self._notify_update_available()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check plugin updates
|
||||||
|
self._check_plugin_updates(silent)
|
||||||
|
|
||||||
|
if not silent and not self.available_update:
|
||||||
|
self._notify_up_to_date()
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[AutoUpdater] Check failed: {e}")
|
||||||
|
if not silent:
|
||||||
|
self._notify_error(str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_plugin_updates(self, silent: bool = False):
|
||||||
|
"""Check for plugin updates from the plugin repository."""
|
||||||
|
try:
|
||||||
|
# Fetch plugin manifest
|
||||||
|
manifest_url = "https://git.lemonlink.eu/impulsivefps/EU-Utility-Plugins-Repo/raw/branch/main/manifest.json"
|
||||||
|
|
||||||
|
with urllib.request.urlopen(manifest_url, timeout=10) as response:
|
||||||
|
manifest = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
plugins_dir = Path("plugins")
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
for plugin_info in manifest.get('plugins', []):
|
||||||
|
plugin_id = plugin_info['id']
|
||||||
|
plugin_path = plugins_dir / plugin_id / "plugin.py"
|
||||||
|
|
||||||
|
if plugin_path.exists():
|
||||||
|
# Check local version vs remote
|
||||||
|
local_version = self._get_local_plugin_version(plugin_path)
|
||||||
|
remote_version = plugin_info['version']
|
||||||
|
|
||||||
|
if self._version_compare(remote_version, local_version) > 0:
|
||||||
|
updates.append({
|
||||||
|
'id': plugin_id,
|
||||||
|
'name': plugin_info['name'],
|
||||||
|
'local_version': local_version,
|
||||||
|
'remote_version': remote_version
|
||||||
|
})
|
||||||
|
|
||||||
|
self.plugin_updates = updates
|
||||||
|
|
||||||
|
if updates and not silent:
|
||||||
|
self._notify_plugin_updates(updates)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[AutoUpdater] Plugin check failed: {e}")
|
||||||
|
|
||||||
|
def _get_local_plugin_version(self, plugin_path: Path) -> str:
|
||||||
|
"""Extract version from plugin file."""
|
||||||
|
try:
|
||||||
|
with open(plugin_path, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Look for version = "x.x.x"
|
||||||
|
import re
|
||||||
|
match = re.search(r'version\s*=\s*["\']([\d.]+)["\']', content)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
def _version_compare(self, v1: str, v2: str) -> int:
|
||||||
|
"""Compare two version strings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
>0 if v1 > v2, 0 if equal, <0 if v1 < v2
|
||||||
|
"""
|
||||||
|
parts1 = [int(x) for x in v1.split('.')]
|
||||||
|
parts2 = [int(x) for x in v2.split('.')]
|
||||||
|
|
||||||
|
for p1, p2 in zip(parts1, parts2):
|
||||||
|
if p1 != p2:
|
||||||
|
return p1 - p2
|
||||||
|
|
||||||
|
return len(parts1) - len(parts2)
|
||||||
|
|
||||||
|
def install_update(self) -> bool:
|
||||||
|
"""Install available update if auto-update is enabled."""
|
||||||
|
if not self.available_update:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.settings.get('auto_update_enabled', False):
|
||||||
|
print("[AutoUpdater] Auto-update disabled, skipping installation")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# TODO: Implement update installation
|
||||||
|
print(f"[AutoUpdater] Installing update to {self.available_update.version}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _notify_update_available(self):
|
||||||
|
"""Notify user of available update."""
|
||||||
|
from PyQt6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
if self.available_update:
|
||||||
|
msg = QMessageBox()
|
||||||
|
msg.setWindowTitle("Update Available")
|
||||||
|
msg.setText(f"EU-Utility {self.available_update.version} is available!")
|
||||||
|
msg.setInformativeText(
|
||||||
|
f"Current: {self.current_version}\n"
|
||||||
|
f"Latest: {self.available_update.version}\n\n"
|
||||||
|
f"Changelog:\n{self.available_update.changelog}\n\n"
|
||||||
|
"Go to Settings → Updates to install."
|
||||||
|
)
|
||||||
|
msg.setIcon(QMessageBox.Icon.Information)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def _notify_plugin_updates(self, updates: List[Dict]):
|
||||||
|
"""Notify user of plugin updates."""
|
||||||
|
from PyQt6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
update_list = "\n".join([
|
||||||
|
f"• {u['name']}: {u['local_version']} → {u['remote_version']}"
|
||||||
|
for u in updates[:5] # Show max 5
|
||||||
|
])
|
||||||
|
|
||||||
|
if len(updates) > 5:
|
||||||
|
update_list += f"\n... and {len(updates) - 5} more"
|
||||||
|
|
||||||
|
msg = QMessageBox()
|
||||||
|
msg.setWindowTitle("Plugin Updates Available")
|
||||||
|
msg.setText(f"{len(updates)} plugin(s) have updates available!")
|
||||||
|
msg.setInformativeText(
|
||||||
|
f"{update_list}\n\n"
|
||||||
|
"Go to Settings → Plugin Store to update."
|
||||||
|
)
|
||||||
|
msg.setIcon(QMessageBox.Icon.Information)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def _notify_up_to_date(self):
|
||||||
|
"""Notify user they are up to date."""
|
||||||
|
from PyQt6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
msg = QMessageBox()
|
||||||
|
msg.setWindowTitle("No Updates")
|
||||||
|
msg.setText("You are running the latest version!")
|
||||||
|
msg.setInformativeText(f"EU-Utility {self.current_version}")
|
||||||
|
msg.setIcon(QMessageBox.Icon.Information)
|
||||||
|
msg.exec()
|
||||||
|
|
||||||
|
def _notify_error(self, error: str):
|
||||||
|
"""Notify user of error."""
|
||||||
|
from PyQt6.QtWidgets import QMessageBox
|
||||||
|
|
||||||
|
msg = QMessageBox()
|
||||||
|
msg.setWindowTitle("Update Check Failed")
|
||||||
|
msg.setText("Could not check for updates.")
|
||||||
|
msg.setInformativeText(f"Error: {error}")
|
||||||
|
msg.setIcon(QMessageBox.Icon.Warning)
|
||||||
|
msg.exec()
|
||||||
Loading…
Reference in New Issue