Source code for simvx.editor.panels.console

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