Skip to content

Commit 762daec

Browse files
committed
feat: integrate editor minimap
1 parent 6de4144 commit 762daec

3 files changed

Lines changed: 240 additions & 2 deletions

File tree

‎core/editor.py‎

Lines changed: 166 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,146 @@ def paintEvent(self, event):
2525
self.codeEditor.lineNumberAreaPaintEvent(event)
2626

2727

28+
class Minimap(QWidget):
29+
"""Kompakte Code-Vorschau mit synchronisiertem Editor-Viewport.
30+
31+
Die Vorschau malt alle Dokumentzeilen in eine schmale, nicht editierbare
32+
Fläche. Ein Klick oder Ziehen positioniert die vertikale Scrollbar des
33+
Haupteditors an der entsprechenden Dokumentstelle.
34+
"""
35+
36+
WIDTH = 110
37+
38+
def __init__(self, editor: "CodeEditor", parent=None):
39+
super().__init__(parent or editor)
40+
self.editor = editor
41+
self.viewport_rect = QRect()
42+
self._dragging = False
43+
self.setFixedWidth(self.WIDTH)
44+
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
45+
self.setCursor(Qt.CursorShape.PointingHandCursor)
46+
self.setToolTip("Minimap: klicken oder ziehen, um zu navigieren")
47+
self.setStyleSheet(
48+
"QWidget { background-color: #1a1a1a; "
49+
"border-left: 1px solid #333; }"
50+
)
51+
52+
editor.textChanged.connect(self.update)
53+
editor.cursorPositionChanged.connect(self.update)
54+
editor.blockCountChanged.connect(lambda _count: self.update())
55+
editor.updateRequest.connect(self._editor_update_requested)
56+
editor.verticalScrollBar().valueChanged.connect(lambda _value: self.update())
57+
editor.document().contentsChanged.connect(self.update)
58+
59+
def _editor_update_requested(self, _rect, _dy):
60+
"""Zeichnet die Vorschau nach Scroll- und Viewport-Updates neu."""
61+
self.update()
62+
63+
def _document_lines(self):
64+
document = self.editor.document()
65+
block = document.firstBlock()
66+
lines = []
67+
while block.isValid():
68+
lines.append(block.text())
69+
block = block.next()
70+
return lines or [""]
71+
72+
def _visible_block_range(self, line_count):
73+
first = self.editor.firstVisibleBlock()
74+
if not first.isValid():
75+
return 0, min(1, line_count)
76+
77+
start = max(0, first.blockNumber())
78+
end = min(line_count, start + 1)
79+
block = first
80+
viewport_height = self.editor.viewport().height()
81+
while block.isValid():
82+
top = int(self.editor.blockBoundingGeometry(block)
83+
.translated(self.editor.contentOffset()).top())
84+
bottom = top + int(self.editor.blockBoundingRect(block).height())
85+
if top > viewport_height:
86+
break
87+
if bottom >= 0:
88+
end = min(line_count, block.blockNumber() + 1)
89+
block = block.next()
90+
return start, max(start + 1, end)
91+
92+
def _update_viewport_rect(self, line_count):
93+
start, end = self._visible_block_range(line_count)
94+
height = max(1, self.height())
95+
top = int(start * height / line_count)
96+
bottom = int(end * height / line_count)
97+
self.viewport_rect = QRect(0, top, self.width(), max(4, bottom - top))
98+
99+
def paintEvent(self, event):
100+
painter = QPainter(self)
101+
painter.fillRect(event.rect(), QColor(26, 26, 26))
102+
103+
lines = self._document_lines()
104+
line_count = len(lines)
105+
self._update_viewport_rect(line_count)
106+
height = max(1, self.height())
107+
width = max(1, self.width() - 8)
108+
max_chars = max(1, max((len(line) for line in lines), default=1))
109+
110+
for index, line in enumerate(lines):
111+
y = int(index * height / line_count)
112+
next_y = int((index + 1) * height / line_count)
113+
line_height = max(1, next_y - y)
114+
text = line.expandtabs(4).strip()
115+
if not text:
116+
continue
117+
bar_width = max(2, min(width, 3 + int(width * len(text) / max_chars)))
118+
if text.startswith(("#", "//", "/*", "*")):
119+
color = QColor(82, 120, 82)
120+
elif text.startswith(("def ", "class ", "function ")):
121+
color = QColor(90, 140, 205)
122+
else:
123+
color = QColor(125, 125, 125)
124+
painter.fillRect(4, y, bar_width, max(1, min(2, line_height)), color)
125+
126+
painter.setPen(Qt.PenStyle.NoPen)
127+
painter.setBrush(QColor(100, 100, 200, 70))
128+
painter.drawRect(self.viewport_rect)
129+
painter.setBrush(Qt.BrushStyle.NoBrush)
130+
painter.setPen(QColor(120, 120, 220))
131+
painter.drawRect(self.viewport_rect)
132+
133+
current_line = self.editor.textCursor().blockNumber()
134+
if 0 <= current_line < line_count:
135+
y = int(current_line * height / line_count)
136+
painter.fillRect(2, y, self.width() - 4, max(1, int(height / line_count)),
137+
QColor(210, 210, 120, 110))
138+
139+
def mousePressEvent(self, event):
140+
if event.button() == Qt.MouseButton.LeftButton:
141+
self._dragging = True
142+
self._scroll_to_position(event.position().y())
143+
event.accept()
144+
return
145+
super().mousePressEvent(event)
146+
147+
def mouseMoveEvent(self, event):
148+
if self._dragging and event.buttons() & Qt.MouseButton.LeftButton:
149+
self._scroll_to_position(event.position().y())
150+
event.accept()
151+
return
152+
super().mouseMoveEvent(event)
153+
154+
def mouseReleaseEvent(self, event):
155+
if event.button() == Qt.MouseButton.LeftButton:
156+
self._dragging = False
157+
event.accept()
158+
return
159+
super().mouseReleaseEvent(event)
160+
161+
def _scroll_to_position(self, y):
162+
"""Setzt die Editor-Scrollbar auf die geklickte Dokumentposition."""
163+
ratio = max(0.0, min(1.0, float(y) / max(1, self.height())))
164+
scrollbar = self.editor.verticalScrollBar()
165+
scrollbar.setValue(round(ratio * scrollbar.maximum()))
166+
167+
28168
class CodeEditor(QPlainTextEdit):
29169
"""Code-Editor mit Zeilennummern, Highlighting, Auto-Completion und Bracket Matching"""
30170

@@ -48,6 +188,8 @@ def __init__(self, parent=None):
48188
self.linter_errors: List[Dict] = []
49189

50190
self.lineNumberArea = LineNumberArea(self)
191+
self.minimap = Minimap(self)
192+
self._minimap_visible = True
51193

52194
self.blockCountChanged.connect(self.updateLineNumberAreaWidth)
53195
self.updateRequest.connect(self.updateLineNumberArea)
@@ -75,11 +217,21 @@ def apply_editor_settings(self, font_family: str = "Consolas", font_size: int =
75217
self.setTabStopWidth(space_width * tab_size)
76218
self.updateLineNumberAreaWidth(0)
77219

78-
79220
# Auto-Completion
80221
self.completer = None
81222
self._provider = None
82223

224+
def set_minimap_visible(self, visible: bool):
225+
"""Zeigt oder verbirgt die Minimap und aktualisiert den Randabstand."""
226+
self._minimap_visible = bool(visible)
227+
self.minimap.setVisible(self._minimap_visible)
228+
self.updateLineNumberAreaWidth(0)
229+
self.minimap.update()
230+
231+
def is_minimap_visible(self) -> bool:
232+
"""Gibt zurück, ob die Minimap für diesen Editor sichtbar sein soll."""
233+
return self._minimap_visible
234+
83235
def set_completer_words(self, words: List[str]):
84236
"""Setzt die Completion-Wörter"""
85237
self.completer = QCompleter(sorted(set(words)), self)
@@ -271,7 +423,18 @@ def lineNumberAreaWidth(self):
271423
return 20 + char_width * digits
272424

273425
def updateLineNumberAreaWidth(self, _):
274-
self.setViewportMargins(self.lineNumberAreaWidth(), 0, 0, 0)
426+
right_margin = self.minimap.width() if self._minimap_visible else 0
427+
self.setViewportMargins(self.lineNumberAreaWidth(), 0, right_margin, 0)
428+
self._position_minimap()
429+
430+
def _position_minimap(self):
431+
if not hasattr(self, "minimap"):
432+
return
433+
cr = self.contentsRect()
434+
width = self.minimap.width()
435+
self.minimap.setGeometry(
436+
QRect(cr.right() - width + 1, cr.top(), width, cr.height())
437+
)
275438

276439
def updateLineNumberArea(self, rect, dy):
277440
if dy:
@@ -287,6 +450,7 @@ def resizeEvent(self, event):
287450
self.lineNumberArea.setGeometry(
288451
QRect(cr.left(), cr.top(), self.lineNumberAreaWidth(), cr.height())
289452
)
453+
self._position_minimap()
290454

291455
def lineNumberAreaPaintEvent(self, event):
292456
painter = QPainter(self.lineNumberArea)

‎tests/test_minimap.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
"""Tests für CodeBox-Minimap und ihre Editor-Einstellung."""
4+
5+
import pytest
6+
from PySide6.QtWidgets import QApplication
7+
8+
from config import load_settings, save_settings
9+
from core.editor import CodeEditor, Minimap
10+
from ui.main_window import MainWindow
11+
12+
13+
@pytest.fixture(scope="module")
14+
def qapp():
15+
app = QApplication.instance()
16+
if app is None:
17+
app = QApplication([])
18+
yield app
19+
20+
21+
def test_minimap_navigation_and_visibility(qapp):
22+
editor = CodeEditor()
23+
editor.resize(520, 280)
24+
editor.setPlainText("\n".join(f"def function_{i}(): return {i}" for i in range(120)))
25+
editor.show()
26+
qapp.processEvents()
27+
28+
assert isinstance(editor.minimap, Minimap)
29+
assert editor.is_minimap_visible()
30+
assert editor.minimap.isVisible()
31+
assert editor.viewportMargins().right() == Minimap.WIDTH
32+
assert editor.verticalScrollBar().maximum() > 0
33+
34+
editor.minimap._scroll_to_position(editor.minimap.height())
35+
qapp.processEvents()
36+
assert editor.verticalScrollBar().value() == editor.verticalScrollBar().maximum()
37+
assert editor.minimap.viewport_rect.top() > 0
38+
39+
editor.set_minimap_visible(False)
40+
assert not editor.is_minimap_visible()
41+
assert not editor.minimap.isVisible()
42+
assert editor.viewportMargins().right() == 0
43+
44+
editor.set_minimap_visible(True)
45+
assert editor.minimap.isVisible()
46+
assert editor.viewportMargins().right() == Minimap.WIDTH
47+
editor.close()
48+
49+
50+
def test_main_window_applies_minimap_setting(qapp, tmp_path, monkeypatch):
51+
settings_file = tmp_path / "settings.json"
52+
monkeypatch.setattr("config._SETTINGS_FILE", settings_file)
53+
save_settings({"show_minimap": False})
54+
55+
window = MainWindow()
56+
tab = window.tab_widget.current_tab()
57+
assert tab is not None
58+
assert not tab.editor.is_minimap_visible()
59+
60+
settings = load_settings()
61+
settings["show_minimap"] = True
62+
window._settings = settings
63+
window._apply_settings()
64+
assert tab.editor.is_minimap_visible()
65+
window.close()

‎ui/main_window.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ def new_file(self):
165165
font_size = int(self._settings.get("font_size", 10))
166166
tab_size = int(self._settings.get("tab_size", 4))
167167
tab.editor.apply_editor_settings(font_family, font_size, tab_size)
168+
tab.editor.set_minimap_visible(
169+
bool(self._settings.get("show_minimap", True))
170+
)
168171

169172
def open_file(self):
170173
path, _ = QFileDialog.getOpenFileName(
@@ -187,6 +190,9 @@ def open_path(self, file_path: Path):
187190
font_size = int(self._settings.get("font_size", 10))
188191
tab_size = int(self._settings.get("tab_size", 4))
189192
tab.editor.apply_editor_settings(font_family, font_size, tab_size)
193+
tab.editor.set_minimap_visible(
194+
bool(self._settings.get("show_minimap", True))
195+
)
190196
if tab and tab.provider:
191197
self.lang_label.setText(tab.provider.get_name())
192198
self.output.run_btn.setEnabled(True)
@@ -423,6 +429,9 @@ def _apply_settings(self):
423429
tab = self.tab_widget.tabs.get(idx)
424430
if tab and tab.editor:
425431
tab.editor.apply_editor_settings(font_family, font_size, tab_size)
432+
tab.editor.set_minimap_visible(
433+
bool(self._settings.get("show_minimap", True))
434+
)
426435

427436
# ---- Ausführen ----
428437

0 commit comments

Comments
 (0)