Source code for simvx.ide.widgets.minimap

"""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