Coverage for vibefpmgui/minimap.py: 86%
184 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""The editor's minimap: a zoomed-out view of the whole file at its right.
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).
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"""
15from __future__ import annotations
17import re
19from qtpy.QtCore import QPoint, QPointF, QRectF, QSize, Qt, QTimer
20from qtpy.QtGui import QColor, QPainter, QPalette, QPolygonF
21from qtpy.QtWidgets import QPlainTextEdit, QWidget
23from .editor_shared import CHANGE_COLORS, COVERAGE_COLORS, MARKER_COLORS, worst_severity
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
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)
56 def sizeHint(self):
57 return QSize(WIDTH, 200)
59 def invalidate(self):
60 """The text or its colors changed: draw the lines again."""
61 self._rows.clear()
62 self.update()
64 # ---------------------------------------------------------- geometry
65 def _lines(self) -> int:
66 return max(1, self.editor.document().blockCount())
68 def _wrapping(self) -> bool:
69 return self.editor.lineWrapMode() != QPlainTextEdit.NoWrap
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()
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()))
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
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))
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: 107 ↛ 105line 107 didn't jump to line 105 because the condition on line 107 was always true
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
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)
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)
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)
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)
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)
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: 172 ↛ 170line 172 didn't jump to line 170 because the condition on line 172 was always true
173 painter.fillRect(QRectF(LEFT, line * LINE_HEIGHT - offset, self.width() - LEFT - 4,
174 LINE_HEIGHT), found)
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: 183 ↛ 182line 183 didn't jump to line 182 because the condition on line 183 was always true
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: 188 ↛ 186line 188 didn't jump to line 186 because the condition on line 188 was always true
189 painter.setBrush(QColor(CHANGE_COLORS[kind]))
190 painter.drawPolygon(QPolygonF([QPointF(0, y - 2.5), QPointF(4, y), QPointF(0, y + 2.5)]))
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)
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()
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) 218 ↛ 221line 218 didn't jump to line 221 because the condition on line 218 was always true
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))))
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
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()))
250 def mouseReleaseEvent(self, event):
251 self._drag = None
253 def wheelEvent(self, event):
254 # Scroll the editor, as if the wheel were over the text.
255 self.editor.wheelEvent(event)