Source code for simvx.ide.panels.output_panel

"""Output panel -- build/lint output with ANSI colour support and clickable file links."""


from __future__ import annotations

import logging
import re
from typing import TYPE_CHECKING

from simvx.core.math.types import Vec2
from simvx.core.ui.core import Control
from simvx.core.ui.rich_text import RichTextLabel
from simvx.core.ui.theme import get_theme
from simvx.core.ui.widgets import Button

if TYPE_CHECKING:
    from ..state import IDEState

log = logging.getLogger(__name__)

_HEADER_H = 28.0

# Matches patterns like "file.py:42:" or "file.py:42:10:"
_FILE_LINE_RE = re.compile(r"^(.+?):(\d+)(?::(\d+))?[:\s]")


[docs] class IDEOutputPanel(Control): """Read-only output panel for build results and lint output with ANSI colour support.""" def __init__(self, state: IDEState, title: str = "Output", **kwargs): super().__init__(**kwargs) self.name = title self._state = state self._title = title theme = get_theme() # Clear button self._btn_clear = Button("Clear") self._btn_clear.size = Vec2(50, 22) self._btn_clear.font_size = 11.0 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) # Rich text label for output content self._label = RichTextLabel() self._label.font_size = 13.0 self._label.bg_colour = theme.bg_dark self._label.max_lines = 5000 self.add_child(self._label) # Store raw lines for click navigation self._raw_lines: list[str] = [] # Connect diagnostics state.diagnostics_updated.connect(self._on_diagnostics_updated) # Initial message self.append("SimVX IDE Output\n")
[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 self._label.bg_colour = theme.bg_dark
# -- Public API ------------------------------------------------------------
[docs] def append(self, text: str): """Add text to the output (supports ANSI escape codes).""" self._raw_lines.extend(text.split("\n")) # Trim to match label limit if len(self._raw_lines) > self._label.max_lines: self._raw_lines = self._raw_lines[-self._label.max_lines:] self._label.append(text)
[docs] def clear(self): """Clear all output.""" self._raw_lines.clear() self._label.clear()
# -- Internal -------------------------------------------------------------- def _on_diagnostics_updated(self, path: str, diagnostics: list): """Format diagnostics as text output.""" if not diagnostics: return severity_labels = {1: "error", 2: "warning", 3: "info", 4: "hint"} severity_colours = { 1: "\033[31m", 2: "\033[33m", 3: "\033[34m", 4: "\033[90m", } reset = "\033[0m" short = self._state.relative_path(path) for diag in diagnostics: sev = severity_labels.get(diag.severity, "info") colour = severity_colours.get(diag.severity, reset) src = f" [{diag.source}]" if diag.source else "" self.append(f"{short}:{diag.line}:{diag.col_start}: {colour}{sev}{reset}: {diag.message}{src}") def _on_gui_input(self, event): # Click on output line -- try to navigate to file:line if event.button == 1 and event.pressed: _, y, _, h = self.get_global_rect() content_y = y + _HEADER_H if not self._raw_lines: return py = event.position.y if hasattr(event.position, "y") else event.position[1] if py < content_y: return # Determine which line was clicked lh = self._label.line_height() line_idx = int((py - content_y + self._label.scroll_y) / lh) if 0 <= line_idx < len(self._raw_lines): self._try_navigate(self._raw_lines[line_idx]) # Forward scroll events to the rich label if event.key in ("scroll_up", "scroll_down"): self._sync_label_rect() self._label._on_gui_input(event) def _try_navigate(self, line: str): """Parse 'file.py:42:' pattern and emit goto_requested.""" # Strip ANSI codes for matching clean = re.sub(r"\033\[[0-9;]*m", "", line) m = _FILE_LINE_RE.match(clean) if m: filepath = m.group(1) lineno = int(m.group(2)) col = int(m.group(3)) if m.group(3) else 0 self._state.goto_requested.emit(filepath, lineno, col) def _sync_label_rect(self): x, y, w, h = self.get_global_rect() label_y = y + _HEADER_H label_h = max(0, h - _HEADER_H) self._label.rect_override = (x, label_y, w, label_h) # -- Layout / Draw ---------------------------------------------------------
[docs] def process(self, dt: float): _, _, w, h = self.get_rect() self._btn_clear.position = Vec2(w - 56, 3) self._label.position = Vec2(0, _HEADER_H) self._label.size = Vec2(w, max(0, h - _HEADER_H))
[docs] def draw(self, renderer): theme = get_theme() x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, theme.panel_bg) # Header renderer.draw_filled_rect(x, y, w, _HEADER_H, theme.header_bg) scale = 12.0 / 14.0 renderer.draw_text_coloured(self._title, 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) # Sync label rect for drawing self._sync_label_rect()