"""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, Control, TextEdit, Vec2
from simvx.core.ui.multiline import MultiLineTextEdit
[docs]
class ReplPanel(Control):
"""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.
"""
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)
self.size = Vec2(600, 200)
# Output buffer
self._output_lines: list[str] = []
self._max_lines = 1000
# Command history
self._history: list[str] = []
self._history_index = 0
self._input_text = ""
# Persistent namespace across commands
self._namespace: dict = {"__builtins__": __builtins__}
# Child widgets ------------------------------------------------
# Output area (read-only multi-line)
self._output = MultiLineTextEdit()
self._output.read_only = True
self._output.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)
# Input line (single-line)
self._input = TextEdit(placeholder="Enter command...")
self._input.bg_colour = (0.1, 0.1, 0.1, 1.0)
self._input.text_colour = (1.0, 1.0, 1.0, 1.0)
self._input.border_colour = (0.3, 0.3, 0.3, 1.0)
self._input.text_submitted.connect(self._on_input_submitted)
# Clear button
self._clear_btn = 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._history.append(code)
self._history_index = len(self._history)
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 callbacks
# ------------------------------------------------------------------
def _on_input_submitted(self, text: str):
"""Called when the user presses Enter in the input line."""
cmd = text.strip()
if cmd:
self._execute(cmd)
self._input.text = ""
self._input.cursor_pos = 0
self._input_text = ""
# ------------------------------------------------------------------
# GUI input (history navigation via up/down)
# ------------------------------------------------------------------
def _on_gui_input(self, event):
"""Handle scroll, up/down arrow for command history."""
if event.key in ("scroll_up", "scroll_down"):
self._output._on_gui_input(event)
return
if self._output._dragging_text or self._output._dragging_scrollbar:
self._output._on_gui_input(event)
return
if event.button == 1 and event.pressed and event.position:
if self._output.is_point_inside(event.position):
self._output._on_gui_input(event)
return
elif self._input.is_point_inside(event.position):
self._input._on_gui_input(event)
return
if event.button == 1 and not event.pressed:
self._output._on_gui_input(event)
self._input._on_gui_input(event)
return
if self._output.focused and event.key in ("ctrl+c", "ctrl+a", "ctrl+d"):
self._output._on_gui_input(event)
return
if self._output.focused and event.key in ("left", "right", "up", "down", "home", "end"):
self._output._on_gui_input(event)
return
if not self._input.focused:
return
if event.key == "up" and event.pressed:
if self._history and self._history_index > 0:
if self._history_index == len(self._history):
self._input_text = self._input.text
self._history_index -= 1
self._input.text = self._history[self._history_index]
self._input.cursor_pos = len(self._input.text)
return
if event.key == "down" and event.pressed:
if self._history_index < len(self._history):
self._history_index += 1
if self._history_index == len(self._history):
self._input.text = self._input_text
else:
self._input.text = self._history[self._history_index]
self._input.cursor_pos = len(self._input.text)
return
# ------------------------------------------------------------------
# Layout
# ------------------------------------------------------------------
def _layout_children(self):
"""Position output area, input line, and clear button."""
x, y, w, h = self.get_global_rect()
header_h = 26.0
input_h = 30.0
pad = 2.0
btn_w, btn_h = self._clear_btn.size[0], self._clear_btn.size[1]
self._clear_btn.position = Vec2(x + w - btn_w - pad, y + (header_h - btn_h) / 2)
out_y = y + header_h
out_h = h - header_h - input_h - pad
self._output.position = Vec2(x, out_y)
self._output.size = Vec2(w, max(out_h, 20.0))
self._input.position = Vec2(x, y + h - input_h)
self._input.size = Vec2(w, input_h)
# ------------------------------------------------------------------
# Process / Draw
# ------------------------------------------------------------------
[docs]
def process(self, dt: float):
self._output.process(dt)
self._input.process(dt)
[docs]
def draw(self, renderer):
self._layout_children()
x, y, w, h = self.get_global_rect()
renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
header_h = 26.0
renderer.draw_rect((x, y), (w, header_h), colour=(0.16, 0.16, 0.16, 1.0), filled=True)
scale = 11.0 / 14.0
renderer.draw_text("REPL", (x + 8, y + 5), colour=(0.7, 0.7, 0.7, 1.0), scale=scale)
renderer.draw_line((x, y + header_h), (x + w, y + header_h), colour=(0.25, 0.25, 0.25, 1.0))
self._clear_btn.draw(renderer)
self._output.draw(renderer)
input_y = y + h - 30.0
renderer.draw_text(">>>", (x + 4, input_y + 7), colour=(0.5, 0.5, 0.5, 1.0), scale=scale)
self._input.draw(renderer)