Source code for simvx.ide.panels.terminal_panel

"""Terminal panel -- integrated terminal with PTY shell and multiple tabs."""


from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import Signal
from simvx.core.math.types import Vec2
from simvx.core.process_node import ProcessNode
from simvx.core.ui.core import Control
from simvx.core.ui.terminal import TerminalEmulator
from simvx.core.ui.theme import get_theme
from simvx.core.ui.widgets import Button

if TYPE_CHECKING:
    from ..config import IDEConfig
    from ..state import IDEState

log = logging.getLogger(__name__)

# Regex patterns for traceback click-to-navigate
_TRACEBACK_RE = re.compile(r'File "([^"]+)", line (\d+)')
_FILE_LINE_RE = re.compile(r"^(.+?):(\d+)(?::(\d+))?[:\s]")
_ANSI_ESC_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")


[docs] class TerminalPanel(Control): """Integrated terminal panel with PTY shell, header bar, and multiple tabs.""" def __init__(self, state: IDEState, config: IDEConfig | None = None, **kwargs): super().__init__(**kwargs) self.name = "Terminal" self._state = state self._config = config self._started = False self._ctrl_held = False self.goto_requested = Signal() # Emits (path: str, line: int) # Multiple terminal tabs self._terminals: list[dict] = [] # Each: {"proc": ProcessNode, "term": TerminalEmulator, "name": str} self._active_index: int = -1 # Clear button self._btn_clear = Button("Clear") self._btn_clear.size = Vec2(50, 22) self._btn_clear.font_size = 11.0 theme = get_theme() self._btn_clear.bg_colour = theme.btn_bg self._btn_clear.hover_colour = theme.btn_hover self._btn_clear.border_colour = theme.border_light self._btn_clear.text_colour = theme.text_dim self._btn_clear.pressed.connect(self._clear) self.add_child(self._btn_clear) # Connect run_requested state.run_requested.connect(self.run_file)
[docs] def refresh_theme(self): """Re-apply theme colours after a theme change.""" theme = get_theme() self._btn_clear.bg_colour = theme.btn_bg self._btn_clear.hover_colour = theme.btn_hover self._btn_clear.border_colour = theme.border_light self._btn_clear.text_colour = theme.text_dim
# -- Public API ------------------------------------------------------------
[docs] def run_command(self, cmd: str): """Send a command string to the active terminal (appends newline).""" entry = self._get_current_terminal() if entry is None: entry = self._add_terminal() if not entry["proc"].running: self._start_shell(entry) entry["proc"].write(cmd + "\n")
[docs] def run_file(self, path: str): """Run a Python file in the active terminal.""" python = self._config.get_python_command(self._state.project_root) if self._config else "python" self.run_command(f"{python} {path}")
# -- Tab management -------------------------------------------------------- def _add_terminal(self, name: str = "") -> dict: """Create a new terminal tab.""" idx = len(self._terminals) if not name: name = f"Terminal {idx + 1}" term = TerminalEmulator(name=name) term.font_size = 13.0 term.cols = 120 term.rows = 24 self.add_child(term) proc = ProcessNode(use_pty=True) entry = {"proc": proc, "term": term, "name": name} self._terminals.append(entry) self._active_index = idx self._start_shell(entry) self._update_tab_visibility() return entry def _close_terminal(self, index: int): """Close a terminal tab.""" if index < 0 or index >= len(self._terminals): return entry = self._terminals.pop(index) entry["proc"].stop() self.remove_child(entry["term"]) if self._active_index >= len(self._terminals): self._active_index = max(0, len(self._terminals) - 1) self._update_tab_visibility() def _get_current_terminal(self) -> dict | None: """Return the active terminal entry.""" if 0 <= self._active_index < len(self._terminals): return self._terminals[self._active_index] return None def _update_tab_visibility(self): """Show only the active terminal, hide others.""" for i, entry in enumerate(self._terminals): entry["term"].visible = (i == self._active_index) # -- Internal -------------------------------------------------------------- def _start_shell(self, entry: dict): """Start the shell process with venv-aware environment.""" shell = os.environ.get("SHELL", "/bin/bash") if self._config: entry["proc"].env = self._config.get_env(self._state.project_root) entry["term"].attach(entry["proc"]) entry["proc"].process_exited.connect(lambda code, e=entry: self._on_exit(code, e)) entry["proc"].start(shell) self._started = True def _clear(self): """Clear the active terminal.""" entry = self._get_current_terminal() if entry and entry["proc"].running: entry["proc"].write("clear\n") def _on_exit(self, code: int, entry: dict): """Handle shell exit -- display message.""" entry["term"].write(f"\r\n[Process exited with code {code}]\r\n")
[docs] def ready(self): """Start the first shell on entry into the scene tree.""" if not self._started: self._add_terminal()
# -- Input ----------------------------------------------------------------- def _on_gui_input(self, event): # Handle tab bar clicks if not (hasattr(event, "button") and event.button == 1 and event.pressed): return theme = get_theme() _, gy, _, _ = self.get_global_rect() py = event.position.y if hasattr(event.position, "y") else event.position[1] px = event.position.x if hasattr(event.position, "x") else event.position[0] tab_top = gy + theme.header_h tab_bottom = tab_top + theme.tab_h if py < tab_top or py > tab_bottom: return # Check "+" button (rightmost in tab bar) gx, _, w, _ = self.get_global_rect() plus_x = gx + w - 28 if px >= plus_x: self._add_terminal() return # Check tab headers tx = gx + 4 for i, entry in enumerate(self._terminals): tab_w = max(80, len(entry["name"]) * 8 + 24) if tx <= px < tx + tab_w: # Check close button (x) area -- rightmost 18px of tab, only if >1 tab if len(self._terminals) > 1 and px >= tx + tab_w - 18: self._close_terminal(i) else: self._active_index = i self._update_tab_visibility() return tx += tab_w + 2 # -- Traceback click-to-navigate ------------------------------------------- def _try_navigate_line(self, text: str) -> bool: """Try to parse a file:line reference from text and emit goto_requested.""" clean = _ANSI_ESC_RE.sub("", text) m = _TRACEBACK_RE.search(clean) if m: path, line = m.group(1), int(m.group(2)) if Path(path).is_file(): self._state.goto_requested.emit(path, line - 1, 0) return True m = _FILE_LINE_RE.match(clean) if m: path, line = m.group(1), int(m.group(2)) col = int(m.group(3)) if m.group(3) else 0 if Path(path).is_file(): self._state.goto_requested.emit(path, line - 1, col) return True return False # -- Layout / Draw ---------------------------------------------------------
[docs] def process(self, dt: float): theme = get_theme() _, _, w, h = self.get_rect() # Header layout self._btn_clear.position = Vec2(w - 56, 3) # Terminal fills below header + tab bar term_y = theme.header_h + theme.tab_h term_h = max(0, h - theme.header_h - theme.tab_h) for entry in self._terminals: term = entry["term"] term.position = Vec2(0, term_y) # Resize terminal grid to fit cw = float(term.font_size) * 0.6 ch = float(term.font_size) * 1.4 new_cols = max(10, int(w / cw)) new_rows = max(2, int(term_h / ch)) if new_cols != int(term.cols) or new_rows != int(term.rows): term.resize(new_cols, new_rows) term.size = Vec2(w, term_h) # Poll ALL terminal processes for entry in self._terminals: if entry["proc"].running: entry["proc"].process(dt)
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() header_h = theme.header_h tab_h = theme.tab_h renderer.draw_filled_rect(x, y, w, h, theme.bg_black) # Header bar renderer.draw_filled_rect(x, y, w, header_h, theme.header_bg) scale = 12.0 / 14.0 renderer.draw_text_coloured("Terminal", x + 8, y + (header_h - 12) / 2, scale, theme.text) renderer.draw_line_coloured(x, y + header_h, x + w, y + header_h, theme.border) # Empty state message if not self._terminals: renderer.draw_text_coloured("No terminal running", x + 8, y + header_h + 8, scale, theme.text_dim) return # Tab bar tab_y = y + header_h renderer.draw_filled_rect(x, tab_y, w, tab_h, theme.header_bg) small_scale = scale * 0.9 tx = x + 4 for i, entry in enumerate(self._terminals): tab_w = max(80, len(entry["name"]) * 8 + 24) # Active tab highlight if i == self._active_index: renderer.draw_filled_rect(tx, tab_y, tab_w, tab_h, theme.tab_active) renderer.draw_text_coloured(entry["name"], tx + 6, tab_y + (tab_h - 11) / 2, small_scale, theme.text) else: renderer.draw_text_coloured(entry["name"], tx + 6, tab_y + (tab_h - 11) / 2, small_scale, theme.text_dim) # Close button (x) when more than 1 tab if len(self._terminals) > 1: renderer.draw_text_coloured("x", tx + tab_w - 14, tab_y + (tab_h - 11) / 2, small_scale, theme.text_dim) tx += tab_w + 2 # "+" button plus_x = x + w - 24 renderer.draw_text_coloured("+", plus_x + 6, tab_y + (tab_h - 11) / 2, small_scale, theme.text_dim) renderer.draw_line_coloured(x, tab_y + tab_h, x + w, tab_y + tab_h, theme.border)