"""Plugin System -- Editor plugin architecture with discovery and lifecycle.
Provides an Plugin base class that third-party addons can subclass to
extend the editor with custom tools, inspectors, node types, and menu items.
Plugins are discovered from the ``addons/`` directory in the project root.
Each plugin lives in its own subdirectory with a ``plugin.toml`` manifest:
addons/
my_plugin/
plugin.toml # Manifest (name, version, script, etc.)
plugin.py # Plugin script (subclass of Plugin)
...
plugin.toml format:
[plugin]
name = "My Plugin"
description = "Does something cool"
author = "Name"
version = "1.0.0"
script = "plugin.py"
icon = "icon.png"
The ``@tool`` decorator marks a Node subclass as an editor-time script,
meaning ``process()`` and ``ready()`` run inside the editor, not just in
the game.
"""
import importlib.util
import logging
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
from simvx.core import Control, Signal
if TYPE_CHECKING:
from .state import State
log = logging.getLogger(__name__)
__all__ = [
"Plugin",
"PluginManifest",
"PluginRegistry",
"tool",
]
# ============================================================================
# @tool decorator
# ============================================================================
_TOOL_REGISTRY: set[type] = set()
# ============================================================================
# PluginManifest -- Parsed plugin.toml data
# ============================================================================
[docs]
@dataclass
class PluginManifest:
"""Plugin metadata loaded from plugin.toml."""
name: str = "Unnamed Plugin"
description: str = ""
author: str = ""
version: str = "0.0.1"
script: str = "plugin.py"
icon: str = ""
path: Path = field(default_factory=lambda: Path("."))
[docs]
@classmethod
def load(cls, toml_path: Path) -> PluginManifest | None:
"""Parse a plugin.toml file."""
if not toml_path.exists():
log.warning("Plugin manifest not found: %s", toml_path)
return None
try:
# Use tomllib (Python 3.11+) or fallback to basic parsing
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore[no-redef]
with open(toml_path, "rb") as f:
data = tomllib.load(f)
except ImportError:
# Minimal TOML fallback for simple key=value files
data = {"plugin": _parse_simple_toml(toml_path)}
except Exception as exc:
log.error("Failed to parse %s: %s", toml_path, exc)
return None
plugin_data = data.get("plugin", data)
manifest = cls(
name=plugin_data.get("name", "Unnamed"),
description=plugin_data.get("description", ""),
author=plugin_data.get("author", ""),
version=plugin_data.get("version", "0.0.1"),
script=plugin_data.get("script", "plugin.py"),
icon=plugin_data.get("icon", ""),
path=toml_path.parent,
)
return manifest
[docs]
def to_dict(self) -> dict:
return {
"name": self.name,
"description": self.description,
"author": self.author,
"version": self.version,
"script": self.script,
"icon": self.icon,
"path": str(self.path),
}
def _parse_simple_toml(path: Path) -> dict:
"""Extremely basic TOML parser for simple key=value files."""
result = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("["):
continue
if "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
result[key] = value
return result
# ============================================================================
# Plugin -- Base class for all plugins
# ============================================================================
[docs]
class Plugin:
"""Base class for editor plugins.
Subclass and override ``activate()`` / ``deactivate()`` to add
editor functionality. Use the provided helper methods to register
menus, dock controls, custom types, etc.
Example:
class MyPlugin(Plugin):
def activate(self):
self.add_tool_menu_item("My Tool", self._on_tool_click)
self.add_custom_type("CoolNode", "Node3D", "res://cool_node.py")
def deactivate(self):
self.remove_tool_menu_item("My Tool")
self.remove_custom_type("CoolNode")
"""
def __init__(self, manifest: PluginManifest, editor_state: State | None = None):
self.manifest = manifest
self.editor_state = editor_state
self.enabled = False
# Internal tracking for cleanup
self._tool_menu_items: list[tuple[str, Callable]] = []
self._dock_controls: list[tuple[str, Control]] = []
self._custom_types: list[str] = []
self._inspector_plugins: list[Any] = []
# ---- Lifecycle ----
[docs]
def activate(self):
"""Called when the plugin is enabled. Override to set up UI and hooks."""
[docs]
def deactivate(self):
"""Called when the plugin is disabled. Override to tear down."""
# ---- Tool Menu ----
# ---- Dock Controls ----
[docs]
def add_control_to_dock(self, dock_name: str, control: Control):
"""Add a control widget to an editor dock panel.
The control will be added to the named dock zone (left, right, bottom).
Valid dock_name values: "left", "right", "bottom".
"""
self._dock_controls.append((dock_name, control))
# Wire into the live DockContainer if available
dock = getattr(self.editor_state, "_dock_container", None) if self.editor_state else None
if dock:
from simvx.core import DockPanel
panel = DockPanel(title=control.name or "Plugin", name=f"Plugin_{control.name}")
panel.set_content(control)
dock.add_panel(panel, dock_name)
log.info("Plugin '%s' added dock control to %s", self.manifest.name, dock_name)
[docs]
def remove_control_from_dock(self, control: Control):
"""Remove a previously added dock control."""
self._dock_controls = [(d, c) for d, c in self._dock_controls if c is not control]
# ---- Custom Types ----
[docs]
def add_custom_type(self, type_name: str, base_type: str, script_path: str = "", icon: str = ""):
"""Register a custom node type in the editor's type list."""
self._custom_types.append(type_name)
log.info("Plugin '%s' registered type: %s (extends %s)", self.manifest.name, type_name, base_type)
[docs]
def remove_custom_type(self, type_name: str):
"""Unregister a custom node type."""
if type_name in self._custom_types:
self._custom_types.remove(type_name)
# ---- Inspector Plugins ----
[docs]
def add_inspector_plugin(self, plugin: Any):
"""Register an inspector plugin for custom property editing."""
self._inspector_plugins.append(plugin)
[docs]
def remove_inspector_plugin(self, plugin: Any):
"""Unregister an inspector plugin."""
if plugin in self._inspector_plugins:
self._inspector_plugins.remove(plugin)
# ---- Cleanup ----
def _cleanup(self):
"""Remove all registered items (called on deactivate)."""
self._tool_menu_items.clear()
self._dock_controls.clear()
self._custom_types.clear()
self._inspector_plugins.clear()
# ============================================================================
# PluginRegistry -- Discovery, loading, and lifecycle management
# ============================================================================
[docs]
class PluginRegistry:
"""Discovers, loads, and manages editor plugins.
Scans the ``addons/`` directory for plugin.toml manifests, loads
plugin scripts, and manages activation/deactivation lifecycle.
"""
def __init__(self, editor_state: State | None = None):
self.editor_state = editor_state
self._manifests: dict[str, PluginManifest] = {}
self._plugins: dict[str, Plugin] = {}
self._load_errors: dict[str, str] = {}
# Signals
self.plugin_activated = Signal()
self.plugin_deactivated = Signal()
self.plugin_error = Signal()
# ---- Discovery ----
[docs]
def scan_addons(self, project_path: str | Path) -> list[PluginManifest]:
"""Scan addons/ directory for plugins. Returns discovered manifests."""
addons_dir = Path(project_path) / "addons"
self._manifests.clear()
self._load_errors.clear()
if not addons_dir.is_dir():
return []
manifests = []
for entry in sorted(addons_dir.iterdir()):
if not entry.is_dir():
continue
toml_path = entry / "plugin.toml"
if not toml_path.exists():
continue
manifest = PluginManifest.load(toml_path)
if manifest:
self._manifests[manifest.name] = manifest
manifests.append(manifest)
else:
self._load_errors[entry.name] = "Failed to parse plugin.toml"
log.info("Discovered %d plugins in %s", len(manifests), addons_dir)
return manifests
# ---- Loading ----
[docs]
def load_plugin(self, name: str) -> Plugin | None:
"""Load a plugin by manifest name. Returns the plugin instance."""
manifest = self._manifests.get(name)
if manifest is None:
log.warning("Plugin manifest not found: %s", name)
return None
if name in self._plugins:
return self._plugins[name]
script_path = manifest.path / manifest.script
if not script_path.exists():
err = f"Plugin script not found: {script_path}"
log.error(err)
self._load_errors[name] = err
self.plugin_error.emit(name, err)
return None
try:
module = self._import_script(name, script_path)
except Exception as exc:
err = f"Failed to load plugin script: {exc}"
log.error(err)
self._load_errors[name] = err
self.plugin_error.emit(name, err)
return None
# Find Plugin subclass in the module
plugin_cls = None
for attr_name in dir(module):
obj = getattr(module, attr_name)
if isinstance(obj, type) and issubclass(obj, Plugin) and obj is not Plugin:
plugin_cls = obj
break
if plugin_cls is None:
err = f"No Plugin subclass found in {script_path}"
log.error(err)
self._load_errors[name] = err
return None
plugin = plugin_cls(manifest=manifest, editor_state=self.editor_state)
self._plugins[name] = plugin
return plugin
@staticmethod
def _import_script(name: str, path: Path):
"""Import a Python script as a module."""
module_name = f"simvx_plugin_{name.replace(' ', '_').lower()}"
spec = importlib.util.spec_from_file_location(module_name, str(path))
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
# ---- Activation ----
[docs]
def activate_plugin(self, name: str) -> bool:
"""Activate a loaded plugin."""
plugin = self._plugins.get(name)
if plugin is None:
plugin = self.load_plugin(name)
if plugin is None:
return False
if plugin.enabled:
return True
try:
plugin.activate()
plugin.enabled = True
self.plugin_activated.emit(name)
log.info("Activated plugin: %s", name)
return True
except Exception as exc:
log.error("Failed to activate plugin %s: %s", name, exc)
self.plugin_error.emit(name, str(exc))
return False
[docs]
def deactivate_plugin(self, name: str) -> bool:
"""Deactivate a loaded plugin."""
plugin = self._plugins.get(name)
if plugin is None or not plugin.enabled:
return False
try:
plugin.deactivate()
plugin._cleanup()
plugin.enabled = False
self.plugin_deactivated.emit(name)
log.info("Deactivated plugin: %s", name)
return True
except Exception as exc:
log.error("Failed to deactivate plugin %s: %s", name, exc)
return False
# ---- Query ----
[docs]
def get_manifest(self, name: str) -> PluginManifest | None:
return self._manifests.get(name)
[docs]
def get_plugin(self, name: str) -> Plugin | None:
return self._plugins.get(name)
[docs]
def list_manifests(self) -> list[PluginManifest]:
return list(self._manifests.values())
[docs]
def list_active(self) -> list[str]:
return [name for name, p in self._plugins.items() if p.enabled]
[docs]
@property
def errors(self) -> dict[str, str]:
"""Errors keyed by plugin name (defensive copy)."""
return dict(self._load_errors)
# ---- Bulk operations ----
[docs]
def deactivate_all(self):
"""Deactivate all active plugins."""
for name in list(self._plugins):
self.deactivate_plugin(name)
[docs]
def get_all_custom_types(self) -> list[tuple[str, str]]:
"""Get all custom types from active plugins. Returns (plugin_name, type_name) tuples."""
types = []
for name, plugin in self._plugins.items():
if not plugin.enabled:
continue
for type_name in plugin._custom_types:
types.append((name, type_name))
return types