"""Minimap -- scaled-down code overview with viewport indicator and markers."""
from __future__ import annotations
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_filled_rect(x, y, w, h, theme.bg_darker)
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_filled_rect(x, vp_y, w, vp_h, _VIEWPORT_COLOUR)
renderer.draw_rect_coloured(x, vp_y, w, vp_h, _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_filled_rect(x + w - marker_w - 1, my, marker_w, max(line_h, 2), colour)
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 = renderer.draw_filled_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(ox + 2.0 + col_start * bw, ly, seg_len * bw, render_h, palette[ci])
vi += stride