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