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