Source code for simvx.ide.widgets.minimap

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