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 __future__ import annotations

from dataclasses import dataclass

__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), } # Backward-compatible alias used by tests that import the old name _MARKER_COLORS = _MARKER_COLOURS
[docs] class MarkerMixin: """Mixin providing marker management and rendering for a code editor. Expects the host class to have: - ``_lines: list[str]`` - ``_scroll_y: float`` - ``font_size: float`` - ``show_line_numbers: bool`` - ``size`` (Vec2-like with ``.x``) - ``queue_redraw()`` - ``get_global_rect()`` - ``get_theme()`` - ``_line_y(line_idx)`` """ 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_filled_rect(x + gutter_w, line_y, content_w + 6.0 + 4.0, lh, tint) 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_coloured(wave_x, y1, next_x, y2, 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_filled_rect(dot_x - dot_r, dot_y - dot_r, dot_r * 2, dot_r * 2, best.colour) else: # Small dot for other marker types dot_x = x + 4 dot_r = 3.0 renderer.draw_filled_rect(dot_x - dot_r, dot_y - dot_r, dot_r * 2, dot_r * 2, best.colour) 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_filled_rect(tx, ty, tooltip_w, tooltip_h, theme.popup_bg) renderer.draw_rect_coloured(tx, ty, tooltip_w, tooltip_h, theme.border_light) # Text text_y = ty + 6.0 for line in lines: display = line[:80] renderer.draw_text_coloured(display, tx + 8.0, text_y, scale * 0.85, theme.text) text_y += self.font_size + 2.0