"""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]
# ================================================================
# 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