Source code for simvx.editor.panels.repl_panel

"""REPL Panel -- Interactive Python REPL for live game tree inspection.

Provides an eval/exec console that operates against the live game tree
during play mode.  Outside of play mode, commands are rejected with a
clear error message.

The namespace exposes ``tree`` (the SceneTree), ``root`` (the scene root),
and ``find`` (shortcut for recursive node search).  Command history is
navigable with up/down arrows.
"""

from simvx.core import Button, CommandLine, Container, Label, MultiLineTextEdit, PointerFilter, ScrollEvent, Vec2
from simvx.core.decorators import on_gui_input
from simvx.core.descriptors import Property

_HEADER_H = 26.0
_INPUT_H = 30.0
_PAD = 2.0
#: How many lines one wheel notch over the prompt or the header scrolls the output.
_WHEEL_LINES = 3.0


[docs] class ReplPanel(Container): """Interactive Python REPL for inspecting/modifying the live game tree. Only executes commands when the editor is in play mode. Exposes the game's SceneTree, root node, and a ``find`` helper in the namespace. A header with a Clear button sits over a read-only output view, and a :class:`CommandLine` with history takes the commands. """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") size_x = Property(600.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(200.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") def __init__(self, editor_state=None, **kwargs): super().__init__(**kwargs) self.state = editor_state self.bg_colour = (0.12, 0.12, 0.12, 1.0) # Output buffer self._output_lines: list[str] = [] self._max_lines = 1000 # Persistent namespace across commands self._namespace: dict = {"__builtins__": __builtins__} # Child widgets ------------------------------------------------ # Output area (read-only multi-line): clicking it selects, and Ctrl+C copies. self._output = self.add_child(MultiLineTextEdit(read_only=True, show_line_numbers=False)) self._output.bg_colour = (0.08, 0.08, 0.08, 1.0) self._output.text_colour = (0.85, 0.85, 0.85, 1.0) self._output.border_colour = (0.2, 0.2, 0.2, 1.0) self._prompt = self.add_child(Label(">>>", font_size=11.0)) self._prompt.text_colour = (0.5, 0.5, 0.5, 1.0) self._input = self.add_child(CommandLine(placeholder="Enter command...")) self._input.text_submitted.connect(self._on_input_submitted) self._clear_btn = self.add_child(Button(text="Clear", on_press=self.clear)) self._clear_btn.size = Vec2(48, 24) # Welcome message self._append_output("[REPL] Enter Python expressions during play mode.") # ------------------------------------------------------------------ # Public helpers # ------------------------------------------------------------------
[docs] def clear(self): """Clear all output lines.""" self._output_lines.clear() self._output.text = ""
# ------------------------------------------------------------------ # Output management # ------------------------------------------------------------------ def _append_output(self, text: str): """Append a line to the output buffer, enforcing max_lines.""" for line in text.split("\n"): self._output_lines.append(line) while len(self._output_lines) > self._max_lines: self._output_lines.pop(0) self._output.text = "\n".join(self._output_lines) # ------------------------------------------------------------------ # Command execution # ------------------------------------------------------------------ def _build_namespace(self) -> dict: """Build the execution namespace from the live game tree. Includes common ``simvx.core`` types (Node, Node2D, Vec2, etc.) so users can reference them without explicit imports. """ ns = dict(self._namespace) # Inject common engine types for convenience try: import simvx.core as _core for name in ("Node", "Node2D", "Node3D", "Vec2", "Vec3", "Camera3D", "Sprite2D"): val = getattr(_core, name, None) if val is not None: ns[name] = val except ImportError: pass tree = self._get_game_tree() if tree: ns["tree"] = tree ns["root"] = tree.root ns["find"] = tree.root.find if tree.root else None return ns def _get_game_tree(self): """Return the live game SceneTree, or None.""" if not self.state: return None return self.state.edited_scene def _execute(self, code: str): """Execute code against the game tree.""" if not self.state or not self.state.is_playing: self._append_output("Error: Not in play mode") return self._append_output(f">>> {code}") ns = self._build_namespace() try: result = eval(code, ns) if result is not None: self._append_output(repr(result)) except SyntaxError: try: exec(code, ns) except Exception as e: self._append_output(f"[ERROR] {e}") except Exception as e: self._append_output(f"[ERROR] {e}") # Persist any new bindings back (e.g. variable assignments via exec) self._namespace.update({k: v for k, v in ns.items() if k not in ("__builtins__",)}) # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------ def _on_input_submitted(self, text: str): """Run the command the input line submitted.""" cmd = text.strip() if cmd: self._execute(cmd) @on_gui_input(ScrollEvent) def _scroll_output(self, event) -> bool: """The wheel over the prompt or the header scrolls the output, as it does over the text.""" self._output.scroll_y -= _WHEEL_LINES if event.scroll[1] > 0 else -_WHEEL_LINES return True # ------------------------------------------------------------------ # Layout and drawing # ------------------------------------------------------------------ def _update_layout(self): """Header strip with the Clear button, output in the middle, prompt and input line at the bottom.""" w, h = self.size_x, self.size_y btn_w, btn_h = self._clear_btn.size_x, self._clear_btn.size_y Container._place(self._clear_btn, w - btn_w - _PAD, (_HEADER_H - btn_h) / 2, btn_w, btn_h) Container._place(self._output, 0.0, _HEADER_H, w, max(h - _HEADER_H - _INPUT_H - _PAD, 20.0)) prompt_w = self._prompt.get_minimum_size().x Container._place(self._prompt, 4.0, h - _INPUT_H, prompt_w, _INPUT_H) line_x = 4.0 + prompt_w + 4.0 Container._place(self._input, line_x, h - _INPUT_H, max(0.0, w - line_x), _INPUT_H)
[docs] def on_draw(self, renderer): x, y, w, h = self._local_rect() renderer.rect((x, y), (w, h), fill=self.bg_colour) renderer.rect((x, y), (w, _HEADER_H), fill=(0.16, 0.16, 0.16, 1.0)) renderer.text("REPL", (x + 8, y + 5), fill=(0.7, 0.7, 0.7, 1.0), scale=11.0 / 16.0) renderer.line((x, y + _HEADER_H), (x + w, y + _HEADER_H), stroke=(0.25, 0.25, 0.25, 1.0))