Coverage for vibefpmgui/minimap.py: 86%

184 statements  

« 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. 

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

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: 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) 

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: 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)])) 

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

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)