Coverage for vibefpmgui/editor_issues.py: 66%
158 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"""Issues in the editor's text: their squiggles, and the hover cards over them.
3A part of `vibefpmgui.code_editor.CodeEditor`."""
5from __future__ import annotations
7import html
8from bisect import bisect_left
10from qtpy.QtCore import QPoint, QPointF, QRect, Qt
11from qtpy.QtGui import (
12 QColor,
13 QCursor,
14 QPainter,
15 QPainterPath,
16 QPalette,
17 QPen,
18 QTextCursor,
19)
21from . import hover_card, lint
22from .editor_shared import (
23 DARK_UNDERLINE_COLORS,
24 MARKER_COLORS,
25 word_bounds,
26)
29class EditorIssuesMixin:
30 """The issues' squiggles and hover cards (a part of `vibefpmgui.code_editor.CodeEditor`)."""
32 def set_lint(self, issues: list[lint.LintIssue]):
33 self.lint_issues = issues
34 # The misspelled words in line order, for the squiggles (see squiggle_spans).
35 self._spelling = sorted((i for i in issues if i.severity == "spelling"), key=lambda i: (i.line, i.column))
36 self._spelling_lines = [i.line for i in self._spelling]
37 self.update_extra_selections()
38 self.viewport().update() # (the spelling squiggles: see _paint_squiggles)
40 def replace_word(self, issue, word: str):
41 """A spelling suggestion: the misspelled word becomes ``word`` (if
42 it's still there)."""
43 span = self._lint_span(issue)
44 if span is None: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 return
46 cursor = QTextCursor(self.document())
47 cursor.setPosition(span[0])
48 cursor.setPosition(span[1], QTextCursor.KeepAnchor)
49 if cursor.selectedText() != issue.word: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 return # (the text changed since)
51 cursor.insertText(word)
52 self.setTextCursor(cursor)
54 def _lint_span(self, issue: lint.LintIssue) -> tuple[int, int] | None:
55 """Document positions to underline: the issue's text, only on its
56 first line (a syntax error can span the rest of the file); at least
57 one character so it can be seen and hovered."""
58 block = self.document().findBlockByNumber(issue.line - 1)
59 if not block.isValid(): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 return None
61 length = max(0, block.length() - 1)
62 start = min(max(issue.column - 1, 0), length)
63 end = min(issue.end_column - 1, length) if issue.end_line == issue.line else length
64 if end <= start:
65 start, end = (start, start + 1) if start < length else (max(0, length - 1), length)
66 if end <= start: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 return None # an empty line
68 return block.position() + start, block.position() + end
70 def word_at(self, point: QPoint) -> tuple[int, int, str] | None:
71 """(1-based line, 0-based start column, word) of the name under a
72 viewport point, or None (whitespace, past the end of the line…)."""
73 cursor = self.cursorForPosition(point)
74 if abs(self.cursorRect(cursor).center().x() - point.x()) > self.fontMetrics().horizontalAdvance("M"):
75 return None # the nearest text is too far away
76 text = cursor.block().text()
77 bounds = word_bounds(text, cursor.positionInBlock())
78 if bounds is None: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 return None
80 start, end = bounds
81 return cursor.blockNumber() + 1, start, text[start:end]
83 def lint_card_html(self, issues, other_notes=()) -> str:
84 """fortitude's messages for ``issues`` (with fix links), plus any
85 other notes for the line (compiler, AI review) as plain paragraphs."""
86 hover_card.set_text_color(self.palette().color(QPalette.Text))
87 text = hover_card.lint_notes_html(issues, [self.lint_issues.index(i) for i in issues])
88 for note in other_notes: 88 ↛ 89line 88 didn't jump to line 89 because the loop on line 88 never started
89 text += f"<p style='margin:4px 0 0 0'>{html.escape(note)}</p>"
90 return text
92 def _lint_visible(self, issue) -> bool:
93 """Is the start of an issue's underline within the visible text area
94 (not scrolled off to the side, as a long line's S001 usually is)?"""
95 span = self._lint_span(issue)
96 if span is None:
97 return False
98 cursor = QTextCursor(self.document())
99 cursor.setPosition(span[0])
100 x = self.cursorRect(cursor).left()
101 return 0 <= x < self.viewport().width()
103 def hover_key(self, point: QPoint):
104 """What the mouse is on, for deciding when the hover popup is stale:
105 the name (line, start column), or else the character position."""
106 word = self.word_at(point)
107 return word[:2] if word else ("at", self.cursorForPosition(point).position())
109 def viewportEvent(self, event):
110 if event.type() == event.Type.ToolTip: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true
111 if self.hover_popup.isVisible() and self.hover_popup.anchor == self.hover_key(event.pos()):
112 return True # already showing (and maybe scrolled): leave it
113 cursor = self.cursorForPosition(event.pos())
114 pos = cursor.position()
115 notes = [i for i in self.lint_issues
116 if (span := self._lint_span(i)) and span[0] <= pos <= span[1]]
117 key = self.hover_key(event.pos())
118 if not notes:
119 # An issue whose underline is scrolled out of view (a long
120 # line's S001 starts at column 101) shows anywhere on its line.
121 line = cursor.blockNumber() + 1
122 notes = [i for i in self.lint_issues if i.line == line and not self._lint_visible(i)]
123 if notes:
124 key = ("line", line)
125 if self.hover_popup.isVisible() and self.hover_popup.anchor == key:
126 return True
127 lint_html = self.lint_card_html(notes) if notes else ""
128 word = self.word_at(event.pos()) if self.hover_provider is not None else None
129 self._hover_word = word[:2] if word else None
130 if notes:
131 self.hover_popup.show_at(event.globalPos(), lint_html, key)
132 elif self.hover_popup.anchor != key:
133 self.hover_popup.hide()
134 if word: # the language server's answer is added when it arrives
135 self.hover_provider(word[0], word[1], event.globalPos(), lint_html)
136 return True
137 if event.type() == event.Type.Leave:
138 self.hover_popup.schedule_hide()
139 return super().viewportEvent(event)
141 def show_hover(self, line: int, column: int, global_pos: QPoint, text_html: str):
142 """Show a hover answer, unless the mouse has moved off that name."""
143 if self._hover_word != (line, column):
144 return
145 word = self.word_at(self.viewport().mapFromGlobal(QCursor.pos()))
146 if word is None or word[:2] != (line, column):
147 return
148 self.hover_popup.show_at(global_pos, text_html, (line, column))
150 def hide_popups(self):
151 self.hover_popup.hide()
152 self.completion_popup.hide()
153 self.signature_popup.hide()
155 def squiggle_spans(self, area: QRect) -> list[tuple[float, float, float, str]]:
156 """Where the misspelled words' squiggles go in ``area`` (viewport
157 coordinates): (left, right, y, color) per row a word is on."""
158 issues = self._spelling
159 if not issues:
160 return []
161 dark = self.palette().color(QPalette.Base).lightness() < 128
162 color = (DARK_UNDERLINE_COLORS if dark else MARKER_COLORS)["spelling"]
163 first = self.firstVisibleBlock()
164 top_line = first.blockNumber() + 1
165 offset = self.contentOffset()
166 out = []
167 # (Only the words in view: from the first visible line, until one is below the area.)
168 for issue in issues[bisect_left(self._spelling_lines, top_line):]:
169 block = self.document().findBlockByNumber(issue.line - 1)
170 geometry = self.blockBoundingGeometry(block).translated(offset)
171 if geometry.top() > area.bottom():
172 break
173 if geometry.bottom() < area.top() or not block.isVisible():
174 continue
175 span = self._lint_span(issue)
176 layout = block.layout()
177 if span is None or layout is None: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 continue
179 start, end = span[0] - block.position(), span[1] - block.position()
180 for n in range(layout.lineCount()):
181 row = layout.lineAt(n)
182 a, b = max(start, row.textStart()), min(end, row.textStart() + row.textLength())
183 if a >= b: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 continue
185 x1, x2 = row.cursorToX(a), row.cursorToX(b)
186 x1 = x1[0] if isinstance(x1, tuple) else x1 # (PySide: (x, position))
187 x2 = x2[0] if isinstance(x2, tuple) else x2
188 y = geometry.top() + row.y() + row.ascent() + max(1.5, row.descent() * 0.45)
189 out.append((geometry.left() + x1, geometry.left() + x2, y, color))
190 return out
192 def _paint_squiggles(self, area: QRect):
193 """Misspelled words' wavy underline, drawn here (thicker than the one
194 Qt draws for the other issues, which is easy to miss under a
195 comment's thin text, especially on a dark background)."""
196 spans = self.squiggle_spans(area)
197 if not spans:
198 return
199 painter = QPainter(self.viewport())
200 painter.setRenderHint(QPainter.Antialiasing)
201 size = self.font().pointSizeF() if self.font().pointSizeF() > 0 else 12.0
202 width = max(1.4, size / 8) # pen width
203 height = max(1.6, size / 7) # half the wave's height
204 step = max(2.5, size / 4.5) # half a wave's length
205 for left, right, y, color in spans:
206 path = QPainterPath(QPointF(left, y))
207 x, up = left, True
208 while x < right:
209 nxt = min(x + step, right)
210 path.quadTo(QPointF((x + nxt) / 2, y - height if up else y + height), QPointF(nxt, y))
211 x, up = nxt, not up
212 pen = QPen(QColor(color), width)
213 pen.setCapStyle(Qt.RoundCap)
214 painter.setPen(pen)
215 painter.drawPath(path)
216 painter.end()