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