"""Problems panel -- structured diagnostics list with severity icons and click navigation."""
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import PointerFilter, Property, SceneTree
from simvx.core.input import Pointer, PointerEvent, PointerPhase, ScrollEvent
from simvx.core.ui.core import Control
from simvx.core.ui.theme import get_theme
if TYPE_CHECKING:
from ..state import Diagnostic, State
_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."""
pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test")
def __init__(self, state: State, **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
self._runtime_tree: SceneTree | None = None
state.diagnostics_updated.connect(self._on_diagnostics_updated)
[docs]
def watch_tree(self, tree: SceneTree | None) -> None:
"""List the script errors raised in ``tree`` as runtime problems.
The panel watches one tree at a time: the game a host is running in
process, such as the editor's play mode. Watching another tree, or
``None``, stops listening to the previous one; errors already listed
stay listed.
"""
if tree is self._runtime_tree:
return
if self._runtime_tree is not None:
self._runtime_tree.script_error.disconnect(self._on_runtime_error)
self._runtime_tree = tree
if tree is not None:
tree.script_error.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.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))
# -- Draw ------------------------------------------------------------------
[docs]
def on_draw(self, renderer):
theme = get_theme()
x, y, w, h = self._local_rect()
renderer.rect((x, y), (w, h), fill=theme.panel_bg)
scale = 12.0 / 16.0
# Header bar with counts
renderer.rect((x, y), (w, _HEADER_H), fill=theme.header_bg)
count_text = f"Problems {self._error_count} errors, {self._warning_count} warnings"
renderer.text(count_text, (x + 8, y + (_HEADER_H - 12) / 2), fill=theme.text, scale=scale)
renderer.line((x, y + _HEADER_H), (x + w, y + _HEADER_H), stroke=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.text("No problems detected", (x + 8, content_y + 8), fill=theme.text_dim, scale=scale)
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.rect((x, ry), (w, _ROW_H), fill=theme.selection)
# Severity badge
sev_colour = sev_colours.get(diag.severity, theme.text_dim)
sev_label = _SEV_LABELS.get(diag.severity, "?")
renderer.rect((x + 4, ry + 3), (16, _ROW_H - 6), fill=sev_colour)
renderer.text(sev_label, (x + 7, ry + 4), fill=(1.0, 1.0, 1.0, 1.0), scale=scale * 0.9)
# Message
msg_x = x + 26
msg = diag.message
if len(msg) > 80:
msg = msg[:77] + "..."
renderer.text(msg, (msg_x, ry + 3), fill=theme.text, scale=scale)
# 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.text(loc_text, (x + w - loc_w - 8, ry + 4), fill=theme.text_dim, scale=scale * 0.85)
renderer.pop_clip()