"""Console Panel -- Interactive Python console with log capture.
Provides a read-only output area for log messages and print statements,
a single-line input for executing Python commands, command history
navigation via up/down arrows, and a clear button.
"""
from __future__ import annotations
import logging
import sys
from simvx.core import (
Button,
Control,
Node,
Signal,
TextEdit,
Vec2,
)
from simvx.core.ui.multiline import MultiLineTextEdit
# ---------------------------------------------------------------------------
# Log handler that feeds into the console output
# ---------------------------------------------------------------------------
class _ConsoleLogHandler(logging.Handler):
"""Logging handler that appends formatted records to the console panel."""
def __init__(self, console: ConsolePanel):
super().__init__()
self._console = console
def emit(self, record: logging.LogRecord):
try:
level = record.levelname
if level in ("ERROR", "CRITICAL"):
prefix = "[ERROR]"
elif level == "WARNING":
prefix = "[WARN]"
else:
prefix = "[INFO]"
msg = self.format(record)
self._console._append_output(f"{prefix} {msg}")
except Exception:
self.handleError(record)
# ---------------------------------------------------------------------------
# Stdout redirect that copies writes into the console output
# ---------------------------------------------------------------------------
class _StdoutCapture:
"""File-like wrapper that tees writes to both the real stdout and a console."""
def __init__(self, console: ConsolePanel, real_stdout):
self._console = console
self._real = real_stdout
def write(self, text: str):
if self._real:
self._real.write(text)
if text and text.strip():
for line in text.rstrip("\n").split("\n"):
self._console._append_output(line)
def flush(self):
if self._real:
self._real.flush()
# Needed so `sys.stdout` acts file-like
def fileno(self):
return self._real.fileno() if self._real else -1
def isatty(self):
return False
# ---------------------------------------------------------------------------
# ConsolePanel
# ---------------------------------------------------------------------------
[docs]
class ConsolePanel(Control):
"""Interactive Python console with log output and command input.
Features:
- Read-only output area showing log and print output
- Single-line input with enter-to-execute
- Command history (up/down arrow)
- eval/exec execution with shared namespace
- Logging handler that captures Python log messages
- Optional sys.stdout redirect for print() capture
- 1000-line output cap (oldest lines discarded)
- Clear button to reset output
"""
def __init__(self, editor_state=None, capture_stdout: bool = False,
**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
# Input text tracked separately for history navigation
self._input_text = ""
# Severity tracking: 0=none, 1=info, 2=warn, 3=error
self._worst_severity: int = 0
self.severity_changed = Signal()
# Execution namespace
self._namespace: dict = {"__builtins__": __builtins__}
if self.state:
self._namespace["editor"] = self.state
if hasattr(self.state, "edited_scene"):
self._namespace["scene"] = self.state.edited_scene
# 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)
# Logging handler
self._log_handler = _ConsoleLogHandler(self)
self._log_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
logging.getLogger().addHandler(self._log_handler)
# Optional stdout capture
self._real_stdout = sys.stdout
self._capturing_stdout = False
if capture_stdout:
self.start_stdout_capture()
# Connection to Node.script_error_raised is established in enter_tree()
# and disconnected in exit_tree() to survive IDE bridge panel re-injection.
self._error_connected = False
# Welcome message
self._append_output("[INFO] Console ready. Type Python expressions below.")
# ------------------------------------------------------------------
# Public helpers
# ------------------------------------------------------------------
@property
def worst_severity(self) -> int:
return self._worst_severity
[docs]
def reset_severity(self):
"""Reset severity tracking (call when Console tab is viewed)."""
self._worst_severity = 0
self.severity_changed.emit()
def _on_script_error(self, node, method_name: str, tb: str):
"""Handle runtime script errors from Node._safe_call."""
self._append_output(f"[ERROR] {node.name}.{method_name}() — script error:")
for line in tb.strip().splitlines():
self._append_output(f" {line}")
if self._worst_severity < 3:
self._worst_severity = 3
self.severity_changed.emit()
[docs]
def start_stdout_capture(self):
"""Begin capturing sys.stdout into the console output."""
if not self._capturing_stdout:
self._real_stdout = sys.stdout
sys.stdout = _StdoutCapture(self, self._real_stdout)
self._capturing_stdout = True
[docs]
def stop_stdout_capture(self):
"""Restore sys.stdout to its original value."""
if self._capturing_stdout:
sys.stdout = self._real_stdout
self._capturing_stdout = False
[docs]
def inject(self, name: str, value):
"""Add a name into the console execution namespace."""
self._namespace[name] = value
[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 _execute_command(self, cmd: str):
"""Execute a command string via eval/exec in the console namespace."""
self._history.append(cmd)
self._history_index = len(self._history)
self._append_output(f">>> {cmd}")
try:
result = eval(cmd, self._namespace)
if result is not None:
self._append_output(repr(result))
except SyntaxError:
try:
exec(cmd, self._namespace)
except Exception as e:
self._append_output(f"[ERROR] {e}")
except Exception as e:
self._append_output(f"[ERROR] {e}")
# ------------------------------------------------------------------
# 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_command(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, delegate rest to children."""
# Forward scroll events to the output area
if event.key in ("scroll_up", "scroll_down"):
self._output._on_gui_input(event)
return
# Forward mouse events to the output area for text selection
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
# Forward selection/copy shortcuts to the output area when it has focus
if self._output.focused and event.key in ("ctrl+c", "ctrl+a", "ctrl+d"):
self._output._on_gui_input(event)
return
# Arrow keys / shift+arrow for output text selection when output is focused
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:
# Save current input if starting to navigate
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
# Clear button in header strip (right-aligned)
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
)
# Output area fills the middle
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))
# Input line at the bottom
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()
# Panel background
renderer.draw_filled_rect(x, y, w, h, self.bg_colour)
# Header bar
header_h = 26.0
renderer.draw_filled_rect(x, y, w, header_h, (0.16, 0.16, 0.16, 1.0))
scale = 11.0 / 14.0
renderer.draw_text_coloured(
"Console", x + 8, y + 5, scale, (0.7, 0.7, 0.7, 1.0)
)
# Header bottom border
renderer.draw_line_coloured(
x, y + header_h, x + w, y + header_h, (0.25, 0.25, 0.25, 1.0)
)
# Clear button
self._clear_btn.draw(renderer)
# Output area
self._output.draw(renderer)
# Input prompt indicator
input_y = y + h - 30.0
renderer.draw_text_coloured(
">>>", x + 4, input_y + 7, scale, (0.5, 0.5, 0.5, 1.0)
)
# Input line (offset to leave room for prompt)
self._input.draw(renderer)
# ------------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------------
[docs]
def enter_tree(self):
"""Re-attach log handler and error signal when (re-)entering the tree."""
if not self._error_connected:
Node.script_error_raised.connect(self._on_script_error)
self._error_connected = True
# Re-add log handler (may have been removed by exit_tree during panel re-injection)
root_logger = logging.getLogger()
if self._log_handler not in root_logger.handlers:
root_logger.addHandler(self._log_handler)
[docs]
def exit_tree(self):
"""Remove log handler, disconnect error signal, and restore stdout on removal from tree."""
logging.getLogger().removeHandler(self._log_handler)
if self._error_connected:
Node.script_error_raised.disconnect(self._on_script_error)
self._error_connected = False
self.stop_stdout_capture()