"""Plugin System -- Editor plugin architecture with discovery and lifecycle.
Provides an EditorPlugin 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 EditorPlugin)
...
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.
"""
from __future__ import annotations
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 EditorState
log = logging.getLogger(__name__)
__all__ = [
"EditorPlugin",
"PluginManifest",
"PluginManager",
"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
# ============================================================================
# EditorPlugin -- Base class for all plugins
# ============================================================================
[docs]
class EditorPlugin:
"""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(EditorPlugin):
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: EditorState | 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()
# ============================================================================
# PluginManager -- Discovery, loading, and lifecycle management
# ============================================================================
[docs]
class PluginManager:
"""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: EditorState | None = None):
self.editor_state = editor_state
self._manifests: dict[str, PluginManifest] = {}
self._plugins: dict[str, EditorPlugin] = {}
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) -> EditorPlugin | 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 EditorPlugin 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, EditorPlugin) and obj is not EditorPlugin:
plugin_cls = obj
break
if plugin_cls is None:
err = f"No EditorPlugin 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) -> EditorPlugin | 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]
def get_errors(self) -> dict[str, str]:
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