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)
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
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: the vibefpmgui.code_editor.CodeEditor to show.
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.
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
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
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
mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent, /) -> None