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