Source code for simvx.editor.plugin

"""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()


[docs] def tool(cls: type) -> type: """Mark a Node subclass as an editor-time tool script. Tool scripts have their ``ready()``, ``process(dt)``, and ``draw()`` methods called while the editor is running (not just during play mode). Usage: @tool class MyGizmo(Node3D): def process(self, dt): # This runs in the editor! self.rotation.y += dt * 0.5 """ _TOOL_REGISTRY.add(cls) cls._is_tool = True return cls
[docs] def is_tool(cls_or_instance) -> bool: """Check if a class or instance is a @tool script.""" cls = cls_or_instance if isinstance(cls_or_instance, type) else type(cls_or_instance) return getattr(cls, "_is_tool", False) or cls in _TOOL_REGISTRY
# ============================================================================ # 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 ----
[docs] def add_tool_menu_item(self, label: str, callback: Callable): """Add an item to the editor's Tools menu. The item will appear in the Tools menu when the plugin is active. The callback is invoked when the user clicks the menu item. """ self._tool_menu_items.append((label, callback)) # Wire into the live Tools menu if the editor has one bar = getattr(self.editor_state, "_tools_menu_bar", None) if self.editor_state else None if bar: from simvx.core import MenuItem as MI for menu_name, popup in bar.menus: if menu_name == "Tools": popup.items.insert(-1, MI(separator=True)) popup.items.insert(-1, MI(label, callback=callback)) break log.info("Plugin '%s' added tool menu: %s", self.manifest.name, label)
[docs] def remove_tool_menu_item(self, label: str): """Remove a previously added tool menu item.""" self._tool_menu_items = [(lbl, cb) for lbl, cb in self._tool_menu_items if lbl != label]
# ---- 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_tool_menu_items(self) -> list[tuple[str, str, Callable]]: """Get all tool menu items from active plugins. Returns (plugin_name, label, callback) tuples.""" items = [] for name, plugin in self._plugins.items(): if not plugin.enabled: continue for label, callback in plugin._tool_menu_items: items.append((name, label, callback)) return items
[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