Source code for simvx.core.ui.markers

"""Text marker management for the code editor widget.

Provides inline markers (error squiggles, warnings, breakpoints, bookmarks),
hover tooltip state, and marker drawing routines.
"""

from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..math.types import Vec2
    from .theme import AppTheme

__all__ = ["TextMarker", "_MARKER_COLOURS"]

# ============================================================================
# Text markers (error squiggles, warnings, etc.)
# ============================================================================


[docs] @dataclass(slots=True) class TextMarker: line: int col_start: int col_end: int type: str = "error" # "error", "warning", "info", "highlight" colour: tuple[float, float, float, float] = (1.0, 0.2, 0.2, 0.6) tooltip: str = ""
_MARKER_COLOURS = { "error": (1.0, 0.2, 0.2, 0.6), "warning": (1.0, 0.8, 0.0, 0.6), "info": (0.3, 0.6, 1.0, 0.6), "highlight": (0.5, 0.5, 0.5, 0.4), "breakpoint": (0.94, 0.33, 0.31, 0.8), "bookmark": (0.35, 0.65, 0.95, 0.8), }
[docs] class MarkerMixin: """Mixin providing marker management and rendering for a code editor. The host class (``CodeTextEdit``) supplies the line buffer, scroll offset, font metrics, and several Control helpers; those members are declared below under ``TYPE_CHECKING`` so the type checker sees the contract without creating runtime attributes. """ if TYPE_CHECKING: # Provided by MultiLineTextEdit host. _lines: list[str] _scroll_y: float font_size: float show_line_numbers: bool def _line_y(self, line_index: int) -> float: ... def _font_scale(self) -> float: ... def _line_height(self) -> float: ... # Provided by Control host (writeable size property). size: Vec2 def queue_redraw(self) -> None: ... def get_global_rect(self) -> tuple[float, float, float, float]: ... # Control.get_theme() always returns an AppTheme at runtime (the # default theme and every named variant subclass it); the named # colour attributes used here live on AppTheme, not the base Theme. def get_theme(self) -> AppTheme: ... def _init_markers(self): """Initialise marker state. Call from ``__init__``.""" self._markers: list[TextMarker] = [] # Hover tooltip state self._hover_tooltip: str = "" self._hover_tooltip_line: int = -1 self._hover_tooltip_col: int = -1 self._hover_time: float = 0.0 self._hover_pos: tuple[float, float] = (0.0, 0.0) self._last_hover_line: int = -1 self._last_hover_col: int = -1 # ================================================================ # Public marker API # ================================================================
[docs] def add_marker( self, line: int, col_start: int, col_end: int, type: str = "error", colour: tuple[float, float, float, float] | None = None, tooltip: str = "", ) -> TextMarker: """Add an inline marker (error squiggle, warning, etc.).""" if colour is None: colour = _MARKER_COLOURS.get(type, _MARKER_COLOURS["error"]) marker = TextMarker(line=line, col_start=col_start, col_end=col_end, type=type, colour=colour, tooltip=tooltip) self._markers.append(marker) return marker
[docs] def remove_marker(self, marker: TextMarker): """Remove a specific marker.""" if marker in self._markers: self._markers.remove(marker)
[docs] def clear_markers(self, type: str | None = None): """Clear all markers, or only markers of a specific type.""" if type is None: self._markers.clear() else: self._markers = [m for m in self._markers if m.type != type]
[docs] def get_markers(self, line: int | None = None) -> list[TextMarker]: """Get all markers, or only markers on a specific line.""" if line is None: return list(self._markers) return [m for m in self._markers if m.line == line]
[docs] def set_hover_tooltip(self, text: str, line: int, col: int): """Set a hover tooltip from LSP or other sources.""" self._hover_tooltip = text self._hover_tooltip_line = line self._hover_tooltip_col = col self._hover_time = 0.3 # Show immediately (skip delay)
# ================================================================ # Drawing # ================================================================ def _draw_markers(self, renderer, content_x, scale, lh, first_visible, last_visible, y_offset, x, gutter_w): """Draw squiggle underlines, breakpoint highlights, and gutter indicators for markers.""" marked_lines: set[int] = set() _, wy, _, wh = self.get_global_rect() content_w = self.size.x - gutter_w - 6.0 - 4.0 - 12.0 # padding - scrollbar for marker in self._markers: if marker.line < first_visible or marker.line >= last_visible: continue # Breakpoint markers: full-line tint instead of squiggle if marker.type == "breakpoint": line_y = self._line_y(marker.line) + y_offset tint = (marker.colour[0], marker.colour[1], marker.colour[2], 0.15) renderer.draw_rect((x + gutter_w, line_y), (content_w + 6.0 + 4.0, lh), colour=tint, filled=True) marked_lines.add(marker.line) continue line_text = self._lines[marker.line] if marker.line < len(self._lines) else "" # Calculate squiggle positions start_x = content_x + renderer.text_width(line_text[: marker.col_start], scale) end_x = content_x + renderer.text_width(line_text[: marker.col_end], scale) base_y = self._line_y(marker.line) + y_offset + lh - 2 # baseline # Draw zigzag (squiggle): 3px amplitude, 4px wavelength wave_x = start_x amplitude = 3.0 wavelength = 4.0 up = True while wave_x < end_x: next_x = min(wave_x + wavelength / 2, end_x) y1 = base_y if up else base_y + amplitude y2 = base_y + amplitude if up else base_y renderer.draw_line((wave_x, y1), (next_x, y2), colour=marker.colour) wave_x = next_x up = not up marked_lines.add(marker.line) # Draw gutter indicators for marked lines if self.show_line_numbers and gutter_w > 0: priority = {"breakpoint": 0, "error": 1, "warning": 2, "info": 3, "highlight": 4} for line_idx in marked_lines: if line_idx < first_visible or line_idx >= last_visible: continue # Find highest-priority marker for this line line_markers = [m for m in self._markers if m.line == line_idx] best = min(line_markers, key=lambda m: priority.get(m.type, 99)) dot_y = self._line_y(line_idx) + y_offset + lh / 2 if best.type == "breakpoint": # Prominent filled circle for breakpoints dot_x = x + 8 dot_r = 5.0 renderer.draw_rect( (dot_x - dot_r, dot_y - dot_r), (dot_r * 2, dot_r * 2), colour=best.colour, filled=True ) else: # Small dot for other marker types dot_x = x + 4 dot_r = 3.0 renderer.draw_rect( (dot_x - dot_r, dot_y - dot_r), (dot_r * 2, dot_r * 2), colour=best.colour, filled=True ) def _draw_hover_tooltip(self, renderer): """Render a hover tooltip near the hovered position.""" theme = self.get_theme() x, y, w, h = self.get_global_rect() scale = self._font_scale() lh = self._line_height() text = self._hover_tooltip lines = text.split("\n")[:10] # Max 10 lines max_line_len = min(max((len(ln) for ln in lines), default=0), 80) tooltip_w = max(100.0, max_line_len * self.font_size * 0.6 + 16.0) tooltip_h = len(lines) * (self.font_size + 2) + 12.0 # Position tooltip near the hover location digits = max(2, len(str(len(self._lines)))) approx_gutter = digits * self.font_size * 0.6 + 16.0 tx = x + approx_gutter + (self._hover_tooltip_col * self.font_size * 0.6) ty = y + (self._hover_tooltip_line - self._scroll_y) * lh - tooltip_h - 4.0 # Keep tooltip on screen if tx + tooltip_w > x + w: tx = x + w - tooltip_w - 4.0 if ty < y: ty = y + (self._hover_tooltip_line - self._scroll_y + 1) * lh + 4.0 # Background renderer.draw_rect((tx, ty), (tooltip_w, tooltip_h), colour=theme.popup_bg, filled=True) renderer.draw_rect((tx, ty), (tooltip_w, tooltip_h), colour=theme.border_light) # Text text_y = ty + 6.0 for line in lines: display = line[:80] renderer.draw_text(display, (tx + 8.0, text_y), colour=theme.text, scale=scale * 0.85) text_y += self.font_size + 2.0