"""Problems panel -- structured diagnostics list with severity icons and click navigation."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import Node
from simvx.core.ui.core import Control
from simvx.core.ui.theme import get_theme
if TYPE_CHECKING:
from ..state import Diagnostic, IDEState
log = logging.getLogger(__name__)
_HEADER_H = 28.0
_ROW_H = 22.0
_SEV_LABELS = {1: "E", 2: "W", 3: "I", 4: "H"}
def _sev_colours(theme):
return {1: theme.error, 2: theme.warning, 3: theme.info, 4: theme.text_dim}
[docs]
class ProblemsPanel(Control):
"""Structured list of all diagnostics across open files."""
def __init__(self, state: IDEState, **kwargs):
super().__init__(**kwargs)
self.name = "Problems"
self._state = state
self._entries: list[Diagnostic] = []
self._runtime_errors: list[Diagnostic] = []
self._selected_index: int = -1
self._scroll_y: float = 0.0
self._error_count: int = 0
self._warning_count: int = 0
state.diagnostics_updated.connect(self._on_diagnostics_updated)
Node.script_error_raised.connect(self._on_runtime_error)
# -- Internal --------------------------------------------------------------
def _on_diagnostics_updated(self, path: str, diagnostics: list):
"""Rebuild the full entry list from all known diagnostics + runtime errors."""
self._rebuild_entries()
def _on_runtime_error(self, node, method_name: str, tb: str):
"""Add a runtime script error as a diagnostic entry."""
from ..state import Diagnostic
first_line = tb.strip().splitlines()[-1] if tb.strip() else f"Error in {method_name}"
diag = Diagnostic(
path=f"<runtime:{node.name}>",
line=0,
col_start=0,
col_end=0,
severity=1,
message=f"{node.name}.{method_name}(): {first_line}",
source="runtime",
)
self._runtime_errors.append(diag)
# Cap at 100 runtime errors
if len(self._runtime_errors) > 100:
self._runtime_errors = self._runtime_errors[-100:]
self._rebuild_entries()
def _rebuild_entries(self):
"""Merge LSP diagnostics and runtime errors into the entry list."""
self._entries.clear()
self._error_count = 0
self._warning_count = 0
for _p, diags in self._state.get_all_diagnostics().items():
for d in diags:
self._entries.append(d)
if d.severity == 1:
self._error_count += 1
elif d.severity == 2:
self._warning_count += 1
for d in self._runtime_errors:
self._entries.append(d)
self._error_count += 1
self._entries.sort(key=lambda d: (d.severity, d.path, d.line))
def _on_gui_input(self, event):
if event.key == "scroll_up":
self._scroll_y = max(0, self._scroll_y - 20)
return
if event.key == "scroll_down":
max_scroll = max(0, len(self._entries) * _ROW_H - (self.size.y - _HEADER_H))
self._scroll_y = min(max_scroll, self._scroll_y + 20)
return
if event.button == 1 and event.pressed:
_, y, _, _ = self.get_global_rect()
py = event.position.y if hasattr(event.position, "y") else event.position[1]
row = int((py - y - _HEADER_H + self._scroll_y) / _ROW_H)
if 0 <= row < len(self._entries):
self._selected_index = row
diag = self._entries[row]
self._state.goto_requested.emit(diag.path, diag.line, diag.col_start)
# -- Draw ------------------------------------------------------------------
[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)
scale = 12.0 / 14.0
# Header bar with counts
renderer.draw_filled_rect(x, y, w, _HEADER_H, theme.header_bg)
count_text = f"Problems {self._error_count} errors, {self._warning_count} warnings"
renderer.draw_text_coloured(count_text, 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)
# Content area
content_y = y + _HEADER_H
content_h = max(0, h - _HEADER_H)
renderer.push_clip(x, content_y, w, content_h)
if not self._entries:
renderer.draw_text_coloured("No problems detected", x + 8, content_y + 8, scale, theme.text_dim)
renderer.pop_clip()
return
sev_colours = _sev_colours(theme)
for i, diag in enumerate(self._entries):
ry = content_y + i * _ROW_H - self._scroll_y
if ry + _ROW_H < content_y or ry > content_y + content_h:
continue
# Selection highlight
if i == self._selected_index:
renderer.draw_filled_rect(x, ry, w, _ROW_H, theme.selection)
# Severity badge
sev_colour = sev_colours.get(diag.severity, theme.text_dim)
sev_label = _SEV_LABELS.get(diag.severity, "?")
renderer.draw_filled_rect(x + 4, ry + 3, 16, _ROW_H - 6, sev_colour)
renderer.draw_text_coloured(sev_label, x + 7, ry + 4, scale * 0.9, (1.0, 1.0, 1.0, 1.0))
# Message
msg_x = x + 26
msg = diag.message
if len(msg) > 80:
msg = msg[:77] + "..."
renderer.draw_text_coloured(msg, msg_x, ry + 3, scale, theme.text)
# File:line on the right
short_path = Path(diag.path).name if diag.path else "?"
location = f"{short_path}:{diag.line}"
src = f" [{diag.source}]" if diag.source else ""
loc_text = location + src
loc_w = renderer.text_width(loc_text, scale * 0.85)
renderer.draw_text_coloured(loc_text, x + w - loc_w - 8, ry + 4, scale * 0.85, theme.text_dim)
renderer.pop_clip()