Source code for simvx.ide.panels.problems_panel

"""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))
[docs] def on_gui_input(self, event): # The wheel is claimed here, so it moves this list rather than the # container the panel is docked in. if isinstance(event, ScrollEvent): if event.scroll[1] > 0: self._scroll_y = max(0, self._scroll_y - 20) return True 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 True if not isinstance(event, PointerEvent): return None if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: py = self.to_local(event.position).y row = int((py - _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) return True return None
# -- 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()