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

1"""Issues in the editor's text: their squiggles, and the hover cards over them. 

2 

3A part of `vibefpmgui.code_editor.CodeEditor`.""" 

4 

5from __future__ import annotations 

6 

7import html 

8from bisect import bisect_left 

9 

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) 

20 

21from . import hover_card, lint 

22from .editor_shared import ( 

23 DARK_UNDERLINE_COLORS, 

24 MARKER_COLORS, 

25 word_bounds, 

26) 

27 

28 

29class EditorIssuesMixin: 

30 """The issues' squiggles and hover cards (a part of `vibefpmgui.code_editor.CodeEditor`).""" 

31 

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) 

39 

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) 

53 

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 

69 

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] 

82 

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 

91 

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

102 

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

108 

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) 

140 

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

149 

150 def hide_popups(self): 

151 self.hover_popup.hide() 

152 self.completion_popup.hide() 

153 self.signature_popup.hide() 

154 

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 

191 

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