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