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

__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. 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_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