vibefpmgui.minimap

The editor's minimap: a zoomed-out view of the whole file at its right.

Each line is drawn as small blocks in its syntax colors (one pixel per character, a few pixels per line), with the gutter's indicators on top: change bars and deleted-line marks at the left edge, compiler / lint / AI review markers at the right edge, the current line and find matches. A translucent box shows the part of the file in view; drag it to scroll, or click anywhere to jump there. When the file is taller than the minimap, the minimap scrolls along with the editor (as in VS Code).

Only the visible part is drawn, from per-line block lists cached until the text or its colors change, so big files stay quick.

  1"""The editor's minimap: a zoomed-out view of the whole file at its right.
  2
  3Each line is drawn as small blocks in its syntax colors (one pixel per
  4character, a few pixels per line), with the gutter's indicators on top:
  5change bars and deleted-line marks at the left edge, compiler / lint / AI
  6review markers at the right edge, the current line and find matches. A
  7translucent box shows the part of the file in view; drag it to scroll, or
  8click anywhere to jump there. When the file is taller than the minimap, the
  9minimap scrolls along with the editor (as in VS Code).
 10
 11Only the visible part is drawn, from per-line block lists cached until the
 12text or its colors change, so big files stay quick.
 13"""
 14
 15from __future__ import annotations
 16
 17import re
 18
 19from qtpy.QtCore import QPoint, QPointF, QRectF, QSize, Qt, QTimer
 20from qtpy.QtGui import QColor, QPainter, QPalette, QPolygonF
 21from qtpy.QtWidgets import QPlainTextEdit, QWidget
 22
 23from .editor_shared import CHANGE_COLORS, COVERAGE_COLORS, MARKER_COLORS, worst_severity
 24
 25LINE_HEIGHT = 2.0  # logical pixels per line
 26CHAR_WIDTH = 1.0   # logical pixels per character
 27WIDTH = 104        # the minimap's width
 28LEFT = 6           # text starts after the change bars
 29TAB = 4            # columns a tab counts as
 30WORD_RE = re.compile(r"\S+")
 31ERROR_SEVERITIES = {"error", "fatal error", "lint error"}  # drawn as a band across the minimap
 32CODE_OPACITY = 0.45  # the code's blocks; markers, bars and bands are drawn at full strength
 33
 34
 35class Minimap(QWidget):
 36    def __init__(self, editor, parent=None):
 37        """``editor``: the `vibefpmgui.code_editor.CodeEditor` to show."""
 38        super().__init__(parent)
 39        self.editor = editor
 40        self.setFixedWidth(WIDTH)
 41        self.setMouseTracking(True)
 42        self.setCursor(Qt.ArrowCursor)
 43        self.setToolTip("Minimap: click to jump there, drag the box to scroll")
 44        self._rows: dict[int, list[tuple[float, float, QColor]]] = {}  # line -> blocks
 45        self._drag: float | None = None  # where in the view box the drag started
 46        self._rebuild_timer = QTimer(self)  # (coalesce bursts of edits)
 47        self._rebuild_timer.setSingleShot(True)
 48        self._rebuild_timer.setInterval(150)
 49        self._rebuild_timer.timeout.connect(self.invalidate)
 50        editor.document().contentsChange.connect(lambda *_: self._rebuild_timer.start())
 51        for bar in (editor.verticalScrollBar(),):
 52            bar.valueChanged.connect(self.update)
 53            bar.rangeChanged.connect(self.update)
 54        editor.cursorPositionChanged.connect(self.update)
 55
 56    def sizeHint(self):
 57        return QSize(WIDTH, 200)
 58
 59    def invalidate(self):
 60        """The text or its colors changed: draw the lines again."""
 61        self._rows.clear()
 62        self.update()
 63
 64    # ---------------------------------------------------------- geometry
 65    def _lines(self) -> int:
 66        return max(1, self.editor.document().blockCount())
 67
 68    def _wrapping(self) -> bool:
 69        return self.editor.lineWrapMode() != QPlainTextEdit.NoWrap
 70
 71    def _first_line(self) -> int:
 72        """The first line in view. (Without word wrap, the scroll bar counts
 73        lines; with it, it counts screen rows, so ask for the block.)"""
 74        if self._wrapping():
 75            return self.editor.firstVisibleBlock().blockNumber()
 76        return self.editor.verticalScrollBar().value()
 77
 78    def _visible_lines(self) -> float:
 79        if self._wrapping():
 80            last = self.editor.cursorForPosition(QPoint(0, self.editor.viewport().height() - 1))
 81            return max(1.0, last.blockNumber() - self._first_line() + 1.0)
 82        return max(1.0, self.editor.viewport().height() / max(1, self.editor.fontMetrics().height()))
 83
 84    def _offset(self) -> float:
 85        """How far the minimap's own content is scrolled (when the file is
 86        taller than the minimap): in step with the editor's scroll bar."""
 87        total = self._lines() * LINE_HEIGHT
 88        spare = total - self.height()
 89        if spare <= 0:
 90            return 0.0
 91        bar = self.editor.verticalScrollBar()
 92        fraction = bar.value() / bar.maximum() if bar.maximum() > 0 else 0.0
 93        return spare * fraction
 94
 95    def _view_box(self) -> QRectF:
 96        top = self._first_line() * LINE_HEIGHT - self._offset()
 97        return QRectF(0, top, self.width(), max(self._visible_lines() * LINE_HEIGHT, 6))
 98
 99    # ----------------------------------------------------------- drawing
100    def _row(self, block, default: QColor) -> list[tuple[float, float, QColor]]:
101        """The blocks for one line: (x, width, color) per word, colored like
102        the syntax highlighting."""
103        text = block.text().expandtabs(TAB)
104        colors: list[QColor | None] = [None] * len(text)
105        for fr in block.layout().formats():
106            brush = fr.format.foreground()
107            if brush.style() != Qt.NoBrush:
108                for i in range(fr.start, min(fr.start + fr.length, len(colors))):
109                    colors[i] = brush.color()
110        out = []
111        limit = int((WIDTH - LEFT - 4) / CHAR_WIDTH)
112        for match in WORD_RE.finditer(text[:limit]):
113            start = match.start()
114            run_start, run_color = start, colors[start] or default
115            for i in range(start + 1, match.end() + 1):
116                color = (colors[i] or default) if i < match.end() else None
117                if color is None or color != run_color:
118                    out.append((LEFT + run_start * CHAR_WIDTH, (i - run_start) * CHAR_WIDTH, run_color))
119                    run_start, run_color = i, color
120        return out
121
122    def paintEvent(self, event):
123        editor = self.editor
124        pal = editor.palette()
125        base, text_color = pal.color(QPalette.Base), pal.color(QPalette.Text)
126        dark = base.lightness() < 128
127        painter = QPainter(self)
128        painter.fillRect(self.rect(), base.lighter(108) if dark else base.darker(102))
129        painter.setPen(Qt.NoPen)
130
131        offset = self._offset()
132        doc = editor.document()
133        first = max(0, int(offset / LINE_HEIGHT))
134        last = min(self._lines() - 1, int((offset + self.height()) / LINE_HEIGHT) + 1)
135        default = QColor(text_color)
136
137        # The part of the file in view, behind the text.
138        box = self._view_box()
139        shade = QColor(text_color)
140        shade.setAlpha(28 if dark else 22)
141        painter.fillRect(box, shade)
142
143        # Current line.
144        current = editor.textCursor().blockNumber()
145        if first <= current <= last:
146            cur = QColor(pal.color(QPalette.Highlight))
147            cur.setAlpha(90)
148            painter.fillRect(QRectF(0, current * LINE_HEIGHT - offset, self.width(), LINE_HEIGHT), cur)
149
150        # The text.
151        block = doc.findBlockByNumber(first)
152        number = first
153        painter.setOpacity(CODE_OPACITY)  # (soft, so the markers on top stand out)
154        while block.isValid() and number <= last:
155            rows = self._rows.get(number)
156            if rows is None:
157                rows = self._rows[number] = self._row(block, default)
158            y = number * LINE_HEIGHT - offset
159            for x, w, color in rows:
160                painter.fillRect(QRectF(x, y, w, LINE_HEIGHT - 0.5), color)
161            block = block.next()
162            number += 1
163        painter.setOpacity(1.0)
164
165        # Find matches: a soft yellow band on their lines.
166        if editor.find_selections:
167            found = QColor("#f2c94c")
168            found.setAlpha(150)
169            doc = editor.document()
170            for start, _end in editor.find_selections[:2000]:
171                line = doc.findBlock(start).blockNumber()
172                if first <= line <= last:
173                    painter.fillRect(QRectF(LEFT, line * LINE_HEIGHT - offset, self.width() - LEFT - 4,
174                                            LINE_HEIGHT), found)
175
176        # Change bars (left edge) and deleted-line marks.
177        if editor.show_coverage and editor.coverage:  # (lines the tests didn't run: under the change bars)
178            missed = QColor(COVERAGE_COLORS["missed"])
179            for line, count in editor.coverage.items():
180                if not count and first <= line - 1 <= last:
181                    painter.fillRect(QRectF(0, (line - 1) * LINE_HEIGHT - offset, 3, LINE_HEIGHT), missed)
182        for line, kind in editor.change_marks.items():
183            if first <= line - 1 <= last:
184                painter.fillRect(QRectF(0, (line - 1) * LINE_HEIGHT - offset, 3, LINE_HEIGHT),
185                                 QColor(CHANGE_COLORS[kind]))
186        for line, (kind, _count) in editor.deletions.items():
187            y = (line - 1) * LINE_HEIGHT - offset
188            if -4 <= y <= self.height() + 4:
189                painter.setBrush(QColor(CHANGE_COLORS[kind]))
190                painter.drawPolygon(QPolygonF([QPointF(0, y - 2.5), QPointF(4, y), QPointF(0, y + 2.5)]))
191
192        # Compiler / lint / AI markers (right edge), at least 3 px tall to be seen.
193        # Errors (compiler errors, fortitude syntax errors) get a red band
194        # across the whole width, so they stand out.
195        for line, notes in editor.markers.items():
196            y = (line - 1) * LINE_HEIGHT - offset
197            if -3 <= y <= self.height():
198                worst = worst_severity(notes)
199                color = QColor(MARKER_COLORS.get(worst, "#868e96"))
200                if worst in ERROR_SEVERITIES:
201                    band = QColor(color)
202                    band.setAlpha(200)
203                    painter.fillRect(QRectF(0, y - 0.5, self.width(), max(LINE_HEIGHT, 3)), band)
204                painter.fillRect(QRectF(self.width() - 4, y - 0.5, 4, max(LINE_HEIGHT, 3)), color)
205
206        # The view box's outline, on top.
207        outline = QColor(text_color)
208        outline.setAlpha(70)
209        painter.setPen(outline)
210        painter.setBrush(Qt.NoBrush)
211        painter.drawRect(box.adjusted(0.5, 0.5, -0.5, -0.5))
212        painter.end()
213
214    # ------------------------------------------------------------- mouse
215    def _scroll_to_line(self, line: float):
216        """Scroll so that ``line`` (0-based) is the first in view."""
217        bar = self.editor.verticalScrollBar()
218        if self._wrapping():  # (the scroll bar counts screen rows)
219            block = self.editor.document().findBlockByNumber(max(0, min(self._lines() - 1, int(line))))
220            line = block.firstLineNumber()
221        bar.setValue(round(max(0, min(bar.maximum(), line))))
222
223    def mousePressEvent(self, event):
224        if event.button() != Qt.LeftButton:
225            return
226        y = event.position().y()
227        box = self._view_box()
228        if box.top() <= y <= box.bottom():
229            self._drag = y - box.top()  # drag the box from where it was grabbed
230        else:
231            # Jump: center the view on the clicked line, then drag from its middle.
232            line = (y + self._offset()) / LINE_HEIGHT
233            self._scroll_to_line(line - self._visible_lines() / 2)
234            self._drag = self._view_box().height() / 2
235
236    def mouseMoveEvent(self, event):
237        if self._drag is None or not event.buttons() & Qt.LeftButton:
238            return
239        y = event.position().y() - self._drag  # where the box's top should be
240        total = self._lines() * LINE_HEIGHT
241        box_h = self._view_box().height()
242        if total <= self.height():
243            self._scroll_to_line(y / LINE_HEIGHT)
244        else:
245            # The minimap scrolls too: move in proportion over its height.
246            bar = self.editor.verticalScrollBar()
247            room = max(1.0, self.height() - box_h)
248            bar.setValue(round(max(0.0, min(1.0, y / room)) * bar.maximum()))
249
250    def mouseReleaseEvent(self, event):
251        self._drag = None
252
253    def wheelEvent(self, event):
254        # Scroll the editor, as if the wheel were over the text.
255        self.editor.wheelEvent(event)
LINE_HEIGHT = 2.0
CHAR_WIDTH = 1.0
WIDTH = 104
LEFT = 6
TAB = 4
WORD_RE = re.compile('\\S+')
ERROR_SEVERITIES = {'error', 'lint error', 'fatal error'}
CODE_OPACITY = 0.45
class Minimap(PySide6.QtWidgets.QWidget):
 36class Minimap(QWidget):
 37    def __init__(self, editor, parent=None):
 38        """``editor``: the `vibefpmgui.code_editor.CodeEditor` to show."""
 39        super().__init__(parent)
 40        self.editor = editor
 41        self.setFixedWidth(WIDTH)
 42        self.setMouseTracking(True)
 43        self.setCursor(Qt.ArrowCursor)
 44        self.setToolTip("Minimap: click to jump there, drag the box to scroll")
 45        self._rows: dict[int, list[tuple[float, float, QColor]]] = {}  # line -> blocks
 46        self._drag: float | None = None  # where in the view box the drag started
 47        self._rebuild_timer = QTimer(self)  # (coalesce bursts of edits)
 48        self._rebuild_timer.setSingleShot(True)
 49        self._rebuild_timer.setInterval(150)
 50        self._rebuild_timer.timeout.connect(self.invalidate)
 51        editor.document().contentsChange.connect(lambda *_: self._rebuild_timer.start())
 52        for bar in (editor.verticalScrollBar(),):
 53            bar.valueChanged.connect(self.update)
 54            bar.rangeChanged.connect(self.update)
 55        editor.cursorPositionChanged.connect(self.update)
 56
 57    def sizeHint(self):
 58        return QSize(WIDTH, 200)
 59
 60    def invalidate(self):
 61        """The text or its colors changed: draw the lines again."""
 62        self._rows.clear()
 63        self.update()
 64
 65    # ---------------------------------------------------------- geometry
 66    def _lines(self) -> int:
 67        return max(1, self.editor.document().blockCount())
 68
 69    def _wrapping(self) -> bool:
 70        return self.editor.lineWrapMode() != QPlainTextEdit.NoWrap
 71
 72    def _first_line(self) -> int:
 73        """The first line in view. (Without word wrap, the scroll bar counts
 74        lines; with it, it counts screen rows, so ask for the block.)"""
 75        if self._wrapping():
 76            return self.editor.firstVisibleBlock().blockNumber()
 77        return self.editor.verticalScrollBar().value()
 78
 79    def _visible_lines(self) -> float:
 80        if self._wrapping():
 81            last = self.editor.cursorForPosition(QPoint(0, self.editor.viewport().height() - 1))
 82            return max(1.0, last.blockNumber() - self._first_line() + 1.0)
 83        return max(1.0, self.editor.viewport().height() / max(1, self.editor.fontMetrics().height()))
 84
 85    def _offset(self) -> float:
 86        """How far the minimap's own content is scrolled (when the file is
 87        taller than the minimap): in step with the editor's scroll bar."""
 88        total = self._lines() * LINE_HEIGHT
 89        spare = total - self.height()
 90        if spare <= 0:
 91            return 0.0
 92        bar = self.editor.verticalScrollBar()
 93        fraction = bar.value() / bar.maximum() if bar.maximum() > 0 else 0.0
 94        return spare * fraction
 95
 96    def _view_box(self) -> QRectF:
 97        top = self._first_line() * LINE_HEIGHT - self._offset()
 98        return QRectF(0, top, self.width(), max(self._visible_lines() * LINE_HEIGHT, 6))
 99
100    # ----------------------------------------------------------- drawing
101    def _row(self, block, default: QColor) -> list[tuple[float, float, QColor]]:
102        """The blocks for one line: (x, width, color) per word, colored like
103        the syntax highlighting."""
104        text = block.text().expandtabs(TAB)
105        colors: list[QColor | None] = [None] * len(text)
106        for fr in block.layout().formats():
107            brush = fr.format.foreground()
108            if brush.style() != Qt.NoBrush:
109                for i in range(fr.start, min(fr.start + fr.length, len(colors))):
110                    colors[i] = brush.color()
111        out = []
112        limit = int((WIDTH - LEFT - 4) / CHAR_WIDTH)
113        for match in WORD_RE.finditer(text[:limit]):
114            start = match.start()
115            run_start, run_color = start, colors[start] or default
116            for i in range(start + 1, match.end() + 1):
117                color = (colors[i] or default) if i < match.end() else None
118                if color is None or color != run_color:
119                    out.append((LEFT + run_start * CHAR_WIDTH, (i - run_start) * CHAR_WIDTH, run_color))
120                    run_start, run_color = i, color
121        return out
122
123    def paintEvent(self, event):
124        editor = self.editor
125        pal = editor.palette()
126        base, text_color = pal.color(QPalette.Base), pal.color(QPalette.Text)
127        dark = base.lightness() < 128
128        painter = QPainter(self)
129        painter.fillRect(self.rect(), base.lighter(108) if dark else base.darker(102))
130        painter.setPen(Qt.NoPen)
131
132        offset = self._offset()
133        doc = editor.document()
134        first = max(0, int(offset / LINE_HEIGHT))
135        last = min(self._lines() - 1, int((offset + self.height()) / LINE_HEIGHT) + 1)
136        default = QColor(text_color)
137
138        # The part of the file in view, behind the text.
139        box = self._view_box()
140        shade = QColor(text_color)
141        shade.setAlpha(28 if dark else 22)
142        painter.fillRect(box, shade)
143
144        # Current line.
145        current = editor.textCursor().blockNumber()
146        if first <= current <= last:
147            cur = QColor(pal.color(QPalette.Highlight))
148            cur.setAlpha(90)
149            painter.fillRect(QRectF(0, current * LINE_HEIGHT - offset, self.width(), LINE_HEIGHT), cur)
150
151        # The text.
152        block = doc.findBlockByNumber(first)
153        number = first
154        painter.setOpacity(CODE_OPACITY)  # (soft, so the markers on top stand out)
155        while block.isValid() and number <= last:
156            rows = self._rows.get(number)
157            if rows is None:
158                rows = self._rows[number] = self._row(block, default)
159            y = number * LINE_HEIGHT - offset
160            for x, w, color in rows:
161                painter.fillRect(QRectF(x, y, w, LINE_HEIGHT - 0.5), color)
162            block = block.next()
163            number += 1
164        painter.setOpacity(1.0)
165
166        # Find matches: a soft yellow band on their lines.
167        if editor.find_selections:
168            found = QColor("#f2c94c")
169            found.setAlpha(150)
170            doc = editor.document()
171            for start, _end in editor.find_selections[:2000]:
172                line = doc.findBlock(start).blockNumber()
173                if first <= line <= last:
174                    painter.fillRect(QRectF(LEFT, line * LINE_HEIGHT - offset, self.width() - LEFT - 4,
175                                            LINE_HEIGHT), found)
176
177        # Change bars (left edge) and deleted-line marks.
178        if editor.show_coverage and editor.coverage:  # (lines the tests didn't run: under the change bars)
179            missed = QColor(COVERAGE_COLORS["missed"])
180            for line, count in editor.coverage.items():
181                if not count and first <= line - 1 <= last:
182                    painter.fillRect(QRectF(0, (line - 1) * LINE_HEIGHT - offset, 3, LINE_HEIGHT), missed)
183        for line, kind in editor.change_marks.items():
184            if first <= line - 1 <= last:
185                painter.fillRect(QRectF(0, (line - 1) * LINE_HEIGHT - offset, 3, LINE_HEIGHT),
186                                 QColor(CHANGE_COLORS[kind]))
187        for line, (kind, _count) in editor.deletions.items():
188            y = (line - 1) * LINE_HEIGHT - offset
189            if -4 <= y <= self.height() + 4:
190                painter.setBrush(QColor(CHANGE_COLORS[kind]))
191                painter.drawPolygon(QPolygonF([QPointF(0, y - 2.5), QPointF(4, y), QPointF(0, y + 2.5)]))
192
193        # Compiler / lint / AI markers (right edge), at least 3 px tall to be seen.
194        # Errors (compiler errors, fortitude syntax errors) get a red band
195        # across the whole width, so they stand out.
196        for line, notes in editor.markers.items():
197            y = (line - 1) * LINE_HEIGHT - offset
198            if -3 <= y <= self.height():
199                worst = worst_severity(notes)
200                color = QColor(MARKER_COLORS.get(worst, "#868e96"))
201                if worst in ERROR_SEVERITIES:
202                    band = QColor(color)
203                    band.setAlpha(200)
204                    painter.fillRect(QRectF(0, y - 0.5, self.width(), max(LINE_HEIGHT, 3)), band)
205                painter.fillRect(QRectF(self.width() - 4, y - 0.5, 4, max(LINE_HEIGHT, 3)), color)
206
207        # The view box's outline, on top.
208        outline = QColor(text_color)
209        outline.setAlpha(70)
210        painter.setPen(outline)
211        painter.setBrush(Qt.NoBrush)
212        painter.drawRect(box.adjusted(0.5, 0.5, -0.5, -0.5))
213        painter.end()
214
215    # ------------------------------------------------------------- mouse
216    def _scroll_to_line(self, line: float):
217        """Scroll so that ``line`` (0-based) is the first in view."""
218        bar = self.editor.verticalScrollBar()
219        if self._wrapping():  # (the scroll bar counts screen rows)
220            block = self.editor.document().findBlockByNumber(max(0, min(self._lines() - 1, int(line))))
221            line = block.firstLineNumber()
222        bar.setValue(round(max(0, min(bar.maximum(), line))))
223
224    def mousePressEvent(self, event):
225        if event.button() != Qt.LeftButton:
226            return
227        y = event.position().y()
228        box = self._view_box()
229        if box.top() <= y <= box.bottom():
230            self._drag = y - box.top()  # drag the box from where it was grabbed
231        else:
232            # Jump: center the view on the clicked line, then drag from its middle.
233            line = (y + self._offset()) / LINE_HEIGHT
234            self._scroll_to_line(line - self._visible_lines() / 2)
235            self._drag = self._view_box().height() / 2
236
237    def mouseMoveEvent(self, event):
238        if self._drag is None or not event.buttons() & Qt.LeftButton:
239            return
240        y = event.position().y() - self._drag  # where the box's top should be
241        total = self._lines() * LINE_HEIGHT
242        box_h = self._view_box().height()
243        if total <= self.height():
244            self._scroll_to_line(y / LINE_HEIGHT)
245        else:
246            # The minimap scrolls too: move in proportion over its height.
247            bar = self.editor.verticalScrollBar()
248            room = max(1.0, self.height() - box_h)
249            bar.setValue(round(max(0.0, min(1.0, y / room)) * bar.maximum()))
250
251    def mouseReleaseEvent(self, event):
252        self._drag = None
253
254    def wheelEvent(self, event):
255        # Scroll the editor, as if the wheel were over the text.
256        self.editor.wheelEvent(event)

QWidget(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Minimap(editor, parent=None)
37    def __init__(self, editor, parent=None):
38        """``editor``: the `vibefpmgui.code_editor.CodeEditor` to show."""
39        super().__init__(parent)
40        self.editor = editor
41        self.setFixedWidth(WIDTH)
42        self.setMouseTracking(True)
43        self.setCursor(Qt.ArrowCursor)
44        self.setToolTip("Minimap: click to jump there, drag the box to scroll")
45        self._rows: dict[int, list[tuple[float, float, QColor]]] = {}  # line -> blocks
46        self._drag: float | None = None  # where in the view box the drag started
47        self._rebuild_timer = QTimer(self)  # (coalesce bursts of edits)
48        self._rebuild_timer.setSingleShot(True)
49        self._rebuild_timer.setInterval(150)
50        self._rebuild_timer.timeout.connect(self.invalidate)
51        editor.document().contentsChange.connect(lambda *_: self._rebuild_timer.start())
52        for bar in (editor.verticalScrollBar(),):
53            bar.valueChanged.connect(self.update)
54            bar.rangeChanged.connect(self.update)
55        editor.cursorPositionChanged.connect(self.update)
editor
def sizeHint(self):
57    def sizeHint(self):
58        return QSize(WIDTH, 200)

sizeHint(self, /) -> PySide6.QtCore.QSize

def invalidate(self):
60    def invalidate(self):
61        """The text or its colors changed: draw the lines again."""
62        self._rows.clear()
63        self.update()

The text or its colors changed: draw the lines again.

def paintEvent(self, event):
123    def paintEvent(self, event):
124        editor = self.editor
125        pal = editor.palette()
126        base, text_color = pal.color(QPalette.Base), pal.color(QPalette.Text)
127        dark = base.lightness() < 128
128        painter = QPainter(self)
129        painter.fillRect(self.rect(), base.lighter(108) if dark else base.darker(102))
130        painter.setPen(Qt.NoPen)
131
132        offset = self._offset()
133        doc = editor.document()
134        first = max(0, int(offset / LINE_HEIGHT))
135        last = min(self._lines() - 1, int((offset + self.height()) / LINE_HEIGHT) + 1)
136        default = QColor(text_color)
137
138        # The part of the file in view, behind the text.
139        box = self._view_box()
140        shade = QColor(text_color)
141        shade.setAlpha(28 if dark else 22)
142        painter.fillRect(box, shade)
143
144        # Current line.
145        current = editor.textCursor().blockNumber()
146        if first <= current <= last:
147            cur = QColor(pal.color(QPalette.Highlight))
148            cur.setAlpha(90)
149            painter.fillRect(QRectF(0, current * LINE_HEIGHT - offset, self.width(), LINE_HEIGHT), cur)
150
151        # The text.
152        block = doc.findBlockByNumber(first)
153        number = first
154        painter.setOpacity(CODE_OPACITY)  # (soft, so the markers on top stand out)
155        while block.isValid() and number <= last:
156            rows = self._rows.get(number)
157            if rows is None:
158                rows = self._rows[number] = self._row(block, default)
159            y = number * LINE_HEIGHT - offset
160            for x, w, color in rows:
161                painter.fillRect(QRectF(x, y, w, LINE_HEIGHT - 0.5), color)
162            block = block.next()
163            number += 1
164        painter.setOpacity(1.0)
165
166        # Find matches: a soft yellow band on their lines.
167        if editor.find_selections:
168            found = QColor("#f2c94c")
169            found.setAlpha(150)
170            doc = editor.document()
171            for start, _end in editor.find_selections[:2000]:
172                line = doc.findBlock(start).blockNumber()
173                if first <= line <= last:
174                    painter.fillRect(QRectF(LEFT, line * LINE_HEIGHT - offset, self.width() - LEFT - 4,
175                                            LINE_HEIGHT), found)
176
177        # Change bars (left edge) and deleted-line marks.
178        if editor.show_coverage and editor.coverage:  # (lines the tests didn't run: under the change bars)
179            missed = QColor(COVERAGE_COLORS["missed"])
180            for line, count in editor.coverage.items():
181                if not count and first <= line - 1 <= last:
182                    painter.fillRect(QRectF(0, (line - 1) * LINE_HEIGHT - offset, 3, LINE_HEIGHT), missed)
183        for line, kind in editor.change_marks.items():
184            if first <= line - 1 <= last:
185                painter.fillRect(QRectF(0, (line - 1) * LINE_HEIGHT - offset, 3, LINE_HEIGHT),
186                                 QColor(CHANGE_COLORS[kind]))
187        for line, (kind, _count) in editor.deletions.items():
188            y = (line - 1) * LINE_HEIGHT - offset
189            if -4 <= y <= self.height() + 4:
190                painter.setBrush(QColor(CHANGE_COLORS[kind]))
191                painter.drawPolygon(QPolygonF([QPointF(0, y - 2.5), QPointF(4, y), QPointF(0, y + 2.5)]))
192
193        # Compiler / lint / AI markers (right edge), at least 3 px tall to be seen.
194        # Errors (compiler errors, fortitude syntax errors) get a red band
195        # across the whole width, so they stand out.
196        for line, notes in editor.markers.items():
197            y = (line - 1) * LINE_HEIGHT - offset
198            if -3 <= y <= self.height():
199                worst = worst_severity(notes)
200                color = QColor(MARKER_COLORS.get(worst, "#868e96"))
201                if worst in ERROR_SEVERITIES:
202                    band = QColor(color)
203                    band.setAlpha(200)
204                    painter.fillRect(QRectF(0, y - 0.5, self.width(), max(LINE_HEIGHT, 3)), band)
205                painter.fillRect(QRectF(self.width() - 4, y - 0.5, 4, max(LINE_HEIGHT, 3)), color)
206
207        # The view box's outline, on top.
208        outline = QColor(text_color)
209        outline.setAlpha(70)
210        painter.setPen(outline)
211        painter.setBrush(Qt.NoBrush)
212        painter.drawRect(box.adjusted(0.5, 0.5, -0.5, -0.5))
213        painter.end()

paintEvent(self, event: PySide6.QtGui.QPaintEvent, /) -> None

def mousePressEvent(self, event):
224    def mousePressEvent(self, event):
225        if event.button() != Qt.LeftButton:
226            return
227        y = event.position().y()
228        box = self._view_box()
229        if box.top() <= y <= box.bottom():
230            self._drag = y - box.top()  # drag the box from where it was grabbed
231        else:
232            # Jump: center the view on the clicked line, then drag from its middle.
233            line = (y + self._offset()) / LINE_HEIGHT
234            self._scroll_to_line(line - self._visible_lines() / 2)
235            self._drag = self._view_box().height() / 2

mousePressEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def mouseMoveEvent(self, event):
237    def mouseMoveEvent(self, event):
238        if self._drag is None or not event.buttons() & Qt.LeftButton:
239            return
240        y = event.position().y() - self._drag  # where the box's top should be
241        total = self._lines() * LINE_HEIGHT
242        box_h = self._view_box().height()
243        if total <= self.height():
244            self._scroll_to_line(y / LINE_HEIGHT)
245        else:
246            # The minimap scrolls too: move in proportion over its height.
247            bar = self.editor.verticalScrollBar()
248            room = max(1.0, self.height() - box_h)
249            bar.setValue(round(max(0.0, min(1.0, y / room)) * bar.maximum()))

mouseMoveEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def mouseReleaseEvent(self, event):
251    def mouseReleaseEvent(self, event):
252        self._drag = None

mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None

def wheelEvent(self, event):
254    def wheelEvent(self, event):
255        # Scroll the editor, as if the wheel were over the text.
256        self.editor.wheelEvent(event)

wheelEvent(self, event: PySide6.QtGui.QWheelEvent, /) -> None

staticMetaObject = PySide6.QtCore.QMetaObject("Minimap" inherits "QWidget": )