"""Minimap -- scaled-down code overview with viewport indicator and markers."""
import logging
from dataclasses import dataclass
import numpy as np
from simvx.core import Signal, Vec2
from simvx.core.ui.core import Control, UIInputEvent
from simvx.core.ui.theme import get_theme, theme_generation
log = logging.getLogger(__name__)
_WIDTH = 80.0
_LINE_H = 2.0
_BLOCK_W = 1.5
_VIEWPORT_COLOUR = (1.0, 1.0, 1.0, 0.12)
_VIEWPORT_BORDER = (1.0, 1.0, 1.0, 0.25)
_PY_KEYWORDS = frozenset({
"def", "class", "if", "else", "elif", "for", "while", "return", "import",
"from", "as", "with", "try", "except", "finally", "raise", "yield",
"break", "continue", "pass", "lambda", "and", "or", "not", "in", "is",
"True", "False", "None", "async", "await", "global", "nonlocal", "assert",
})
# Colour indices used in the cached segment data
_C_TEXT = 0
_C_KEYWORD = 1
_C_STRING = 2
_C_COMMENT = 3
[docs]
@dataclass(slots=True)
class MinimapMarker:
"""Marker for a line in the minimap (error, warning, etc.)."""
line: int
severity: int # 1=error, 2=warning, 3=info
def _tokenize_line(line: str, max_chars: int) -> list[tuple[int, int, int]]:
"""Tokenize a line into (col_start, length, colour_index) segments.
Returns an empty list for blank lines.
"""
if not line or line.isspace():
return []
stripped = line.lstrip()
indent = len(line) - len(stripped)
# Comment — single segment
if stripped.startswith("#"):
length = min(len(stripped), max_chars - indent)
return [(indent, length, _C_COMMENT)] if length > 0 else []
segments: list[tuple[int, int, int]] = []
i = 0
n = len(stripped)
limit = max_chars - indent
while i < n and i < limit:
ch = stripped[i]
# String
if ch in ('"', "'"):
end = i + 1
while end < n and stripped[end] != ch:
end += 1
end = min(end + 1, n)
seg_len = min(end - i, limit - i)
if seg_len > 0:
segments.append((indent + i, seg_len, _C_STRING))
i = end
continue
# Word (identifier or keyword)
if ch.isalpha() or ch == '_':
end = i + 1
while end < n and (stripped[end].isalnum() or stripped[end] == '_'):
end += 1
seg_len = min(end - i, limit - i)
if seg_len > 0:
word = stripped[i:end]
ci = _C_KEYWORD if word in _PY_KEYWORDS else _C_TEXT
segments.append((indent + i, seg_len, ci))
i = end
continue
# Other non-space characters
if ch != ' ':
segments.append((indent + i, 1, _C_TEXT))
i += 1
return segments
def _colour_to_rgba8(colour: tuple) -> tuple[int, int, int, int]:
"""Convert a 0.0-1.0 float RGBA tuple to 0-255 uint8 values."""
r = int(min(max(colour[0], 0.0), 1.0) * 255)
g = int(min(max(colour[1], 0.0), 1.0) * 255)
b = int(min(max(colour[2], 0.0), 1.0) * 255)
a = int(min(max(colour[3] if len(colour) > 3 else 1.0, 0.0), 1.0) * 255)
return r, g, b, a
[docs]
class Minimap(Control):
"""Scaled-down code overview, positioned on the right side of the editor.
Each line is rendered as tiny coloured blocks (2px per line).
The visible viewport region is highlighted. Click/drag to scroll.
Performance: code content is rasterized into a CPU pixel buffer and
uploaded as a GPU texture. Each frame draws 1 textured quad + a few
overlay rects. Re-rasterizes only when text, size, or theme changes.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.size = Vec2(_WIDTH, 400)
self._first_visible: int = 0
self._last_visible: int = 0
self._markers: list[MinimapMarker] = []
self._dragging: bool = False
self._total_lines: int = 0
# Cached line segments: list of list[(col_start, length, colour_idx)]
self._line_segments: list[list[tuple[int, int, int]]] = []
self._needs_retokenize: bool = False
# Layout state from last draw (used by click handler)
self._scroll_offset: int = 0
self._visible_lines: int = 0
self._line_h: float = _LINE_H
# Reference to editor's internal line list (avoids join/split copies)
self._editor_lines: list[str] | None = None
# Render-to-texture cache
self._tex_id: int = -1
self._tex_pixels: np.ndarray | None = None
self._cache_key: tuple = ()
self.line_clicked = Signal()
# -- Public API ------------------------------------------------------------
[docs]
def set_lines(self, lines: list[str]):
"""Set the minimap content directly from an editor's line list.
Call this once when the editor is first connected and again via
the editor's ``text_changed`` signal. Much cheaper than ``set_text``
because it avoids the join/split round-trip.
"""
self._editor_lines = lines
self._total_lines = len(lines)
self._needs_retokenize = True
[docs]
def invalidate(self):
"""Mark content as needing re-tokenization on the next draw."""
self._needs_retokenize = True
def _retokenize_if_needed(self):
"""Tokenize cached lines when dirty. Deferred to draw-time so
rapid edits only tokenize once per frame."""
if not self._needs_retokenize:
return
self._needs_retokenize = False
lines = self._editor_lines or []
max_chars = int(self.size.x / _BLOCK_W) if self.size.x > 0 else 53
self._total_lines = len(lines)
self._line_segments = [_tokenize_line(line, max_chars) for line in lines]
[docs]
def set_viewport(self, first_line: int, last_line: int):
self._first_visible = max(0, first_line)
self._last_visible = max(first_line, last_line)
[docs]
def set_markers(self, markers: list[MinimapMarker]):
self._markers = markers
# -- Input -----------------------------------------------------------------
def _on_gui_input(self, event: UIInputEvent):
if event.button == 1:
if event.pressed and self.is_point_inside(event.position):
self._dragging = True
self._click_to_line(event.position)
elif not event.pressed:
self._dragging = False
if self._dragging and event.position:
self._click_to_line(event.position)
def _click_to_line(self, pos):
_, y, _, h = self.get_global_rect()
py = pos.y if hasattr(pos, "y") else pos[1]
if h <= 0 or self._total_lines == 0:
return
# Map pixel position to the visual row in the minimap, then add
# scroll_offset to get the actual source line.
lh = self._line_h
if lh > 0:
visual_row = int((py - y) / lh)
else:
visual_row = int((py - y) / h * self._visible_lines)
line = self._scroll_offset + max(0, min(visual_row, self._visible_lines - 1))
line = max(0, min(line, self._total_lines - 1))
self.line_clicked.emit(line)
# -- Rasterizer ------------------------------------------------------------
def _rasterize(self, palette, w_int: int, h_int: int, line_h: float,
scroll_offset: int, visible_lines: int, total: int, stride: int):
"""Rasterize minimap content into a CPU pixel buffer (RGBA uint8)."""
if self._tex_pixels is None or self._tex_pixels.shape[0] != h_int or self._tex_pixels.shape[1] != w_int:
self._tex_pixels = np.zeros((h_int, w_int, 4), dtype=np.uint8)
else:
self._tex_pixels[:] = 0
pixels = self._tex_pixels
segments = self._line_segments
count = min(visible_lines, total - scroll_offset)
bw = _BLOCK_W
# Pre-convert palette colours to uint8
palette_u8 = [_colour_to_rgba8(c) for c in palette]
vi = 0
render_h = line_h * stride if stride > 1 else line_h
while vi < count:
li = scroll_offset + vi
if li >= total:
break
segs = segments[li]
if segs:
y0 = int(vi * line_h)
y1 = min(int(y0 + render_h), h_int)
if y0 < h_int and y1 > y0:
for col_start, seg_len, ci in segs:
x0 = int(2.0 + col_start * bw)
x1 = min(int(x0 + seg_len * bw), w_int)
if x0 < w_int and x1 > x0:
pixels[y0:y1, x0:x1] = palette_u8[ci]
vi += stride
# -- Draw ------------------------------------------------------------------
[docs]
def draw(self, renderer):
theme = get_theme()
x, y, w, h = self.get_global_rect()
# Background
renderer.draw_rect((x, y), (w, h), colour=theme.bg_darker, filled=True)
self._retokenize_if_needed()
total = self._total_lines
if total == 0:
return
# Scale: fit all lines into the minimap height, capped at _LINE_H per line
line_h = min(_LINE_H, h / max(total, 1))
visible_lines = int(h / line_h) if line_h > 0 else total
# If total lines exceed visible area, offset to center on viewport
scroll_offset = 0
if total > visible_lines:
center = (self._first_visible + self._last_visible) / 2
scroll_offset = int(center - visible_lines / 2)
scroll_offset = max(0, min(scroll_offset, total - visible_lines))
# Store for click handler
self._scroll_offset = scroll_offset
self._visible_lines = visible_lines
self._line_h = line_h
# Compute stride (same logic as old _draw_cached_lines)
count = min(visible_lines, total - scroll_offset)
max_rows = int(h) if h > 0 else count
stride = max(1, (count + max_rows - 1) // max_rows) if count > max_rows and max_rows > 0 else 1
renderer.push_clip(x, y, w, h)
# Render-to-texture: check cache validity
w_int = max(1, int(w))
h_int = max(1, int(h))
cache_key = (total, w_int, h_int, scroll_offset, stride, theme_generation())
if cache_key != self._cache_key or self._tex_id == -1:
palette = (theme.minimap_text, theme.minimap_keyword, theme.minimap_string, theme.minimap_comment)
self._rasterize(palette, w_int, h_int, line_h, scroll_offset, visible_lines, total, stride)
self._cache_key = cache_key
# Upload/update GPU texture
app = getattr(self, "app", None)
engine = getattr(app, "engine", None) if app else None
if engine and self._tex_pixels is not None:
if self._tex_id == -1:
self._tex_id = engine.upload_texture_pixels(self._tex_pixels, w_int, h_int)
else:
engine.update_texture_pixels(self._tex_id, self._tex_pixels, w_int, h_int)
# Draw content: 1 textured quad if we have a texture, else fall back to rects
if self._tex_id >= 0:
renderer.draw_texture(self._tex_id, x, y, w, h)
else:
# Fallback for headless/test renderers without GPU
palette = (theme.minimap_text, theme.minimap_keyword, theme.minimap_string, theme.minimap_comment)
self._draw_cached_lines(renderer, palette, x, y, h, line_h, scroll_offset, visible_lines, total)
# Viewport highlight
vp_start = self._first_visible - scroll_offset
vp_end = self._last_visible - scroll_offset
if vp_start < visible_lines and vp_end >= 0:
vp_y = y + max(0, vp_start) * line_h
vp_h = (min(vp_end, visible_lines) - max(0, vp_start)) * line_h
if vp_h > 0:
renderer.draw_rect((x, vp_y), (w, vp_h), colour=_VIEWPORT_COLOUR, filled=True)
renderer.draw_rect((x, vp_y), (w, vp_h), colour=_VIEWPORT_BORDER)
# Markers (error/warning dots on the right edge)
marker_w = 4.0
severity_colours = {1: theme.error, 2: theme.warning, 3: theme.info}
for marker in self._markers:
mi = marker.line - scroll_offset
if 0 <= mi < visible_lines:
my = y + mi * line_h
colour = severity_colours.get(marker.severity, theme.info)
renderer.draw_rect(
(x + w - marker_w - 1, my), (marker_w, max(line_h, 2)),
colour=colour, filled=True,
)
renderer.pop_clip()
def _draw_cached_lines(self, renderer, palette, ox, oy, h, line_h,
scroll_offset, visible_lines, total):
"""Fallback: render pre-tokenized line segments as coloured rects.
Used when no GPU engine is available (headless tests).
"""
bw = _BLOCK_W
draw_rect = renderer.draw_rect
segments = self._line_segments
count = min(visible_lines, total - scroll_offset)
max_rows = int(h) if h > 0 else count
if count > max_rows and max_rows > 0:
stride = max(1, (count + max_rows - 1) // max_rows)
render_h = line_h * stride
else:
stride = 1
render_h = line_h
vi = 0
while vi < count:
li = scroll_offset + vi
if li >= total:
break
segs = segments[li]
if segs:
ly = oy + vi * line_h
for col_start, seg_len, ci in segs:
draw_rect(
(ox + 2.0 + col_start * bw, ly), (seg_len * bw, render_h),
colour=palette[ci], filled=True,
)
vi += stride