vibefpmgui.editor_issues

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

A part of vibefpmgui.code_editor.CodeEditor.

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

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

def set_lint(self, issues: list[vibefpmgui.lint.LintIssue]):
33    def set_lint(self, issues: list[lint.LintIssue]):
34        self.lint_issues = issues
35        # The misspelled words in line order, for the squiggles (see squiggle_spans).
36        self._spelling = sorted((i for i in issues if i.severity == "spelling"), key=lambda i: (i.line, i.column))
37        self._spelling_lines = [i.line for i in self._spelling]
38        self.update_extra_selections()
39        self.viewport().update()  # (the spelling squiggles: see _paint_squiggles)
def replace_word(self, issue, word: str):
41    def replace_word(self, issue, word: str):
42        """A spelling suggestion: the misspelled word becomes ``word`` (if
43        it's still there)."""
44        span = self._lint_span(issue)
45        if span is None:
46            return
47        cursor = QTextCursor(self.document())
48        cursor.setPosition(span[0])
49        cursor.setPosition(span[1], QTextCursor.KeepAnchor)
50        if cursor.selectedText() != issue.word:
51            return  # (the text changed since)
52        cursor.insertText(word)
53        self.setTextCursor(cursor)

A spelling suggestion: the misspelled word becomes word (if it's still there).

def word_at(self, point: PySide6.QtCore.QPoint) -> tuple[int, int, str] | None:
71    def word_at(self, point: QPoint) -> tuple[int, int, str] | None:
72        """(1-based line, 0-based start column, word) of the name under a
73        viewport point, or None (whitespace, past the end of the line…)."""
74        cursor = self.cursorForPosition(point)
75        if abs(self.cursorRect(cursor).center().x() - point.x()) > self.fontMetrics().horizontalAdvance("M"):
76            return None  # the nearest text is too far away
77        text = cursor.block().text()
78        bounds = word_bounds(text, cursor.positionInBlock())
79        if bounds is None:
80            return None
81        start, end = bounds
82        return cursor.blockNumber() + 1, start, text[start:end]

(1-based line, 0-based start column, word) of the name under a viewport point, or None (whitespace, past the end of the line…).

def lint_card_html(self, issues, other_notes=()) -> str:
84    def lint_card_html(self, issues, other_notes=()) -> str:
85        """fortitude's messages for ``issues`` (with fix links), plus any
86        other notes for the line (compiler, AI review) as plain paragraphs."""
87        hover_card.set_text_color(self.palette().color(QPalette.Text))
88        text = hover_card.lint_notes_html(issues, [self.lint_issues.index(i) for i in issues])
89        for note in other_notes:
90            text += f"<p style='margin:4px 0 0 0'>{html.escape(note)}</p>"
91        return text

fortitude's messages for issues (with fix links), plus any other notes for the line (compiler, AI review) as plain paragraphs.

def hover_key(self, point: PySide6.QtCore.QPoint):
104    def hover_key(self, point: QPoint):
105        """What the mouse is on, for deciding when the hover popup is stale:
106        the name (line, start column), or else the character position."""
107        word = self.word_at(point)
108        return word[:2] if word else ("at", self.cursorForPosition(point).position())

What the mouse is on, for deciding when the hover popup is stale: the name (line, start column), or else the character position.

def viewportEvent(self, event):
110    def viewportEvent(self, event):
111        if event.type() == event.Type.ToolTip:
112            if self.hover_popup.isVisible() and self.hover_popup.anchor == self.hover_key(event.pos()):
113                return True  # already showing (and maybe scrolled): leave it
114            cursor = self.cursorForPosition(event.pos())
115            pos = cursor.position()
116            notes = [i for i in self.lint_issues
117                     if (span := self._lint_span(i)) and span[0] <= pos <= span[1]]
118            key = self.hover_key(event.pos())
119            if not notes:
120                # An issue whose underline is scrolled out of view (a long
121                # line's S001 starts at column 101) shows anywhere on its line.
122                line = cursor.blockNumber() + 1
123                notes = [i for i in self.lint_issues if i.line == line and not self._lint_visible(i)]
124                if notes:
125                    key = ("line", line)
126                    if self.hover_popup.isVisible() and self.hover_popup.anchor == key:
127                        return True
128            lint_html = self.lint_card_html(notes) if notes else ""
129            word = self.word_at(event.pos()) if self.hover_provider is not None else None
130            self._hover_word = word[:2] if word else None
131            if notes:
132                self.hover_popup.show_at(event.globalPos(), lint_html, key)
133            elif self.hover_popup.anchor != key:
134                self.hover_popup.hide()
135            if word:  # the language server's answer is added when it arrives
136                self.hover_provider(word[0], word[1], event.globalPos(), lint_html)
137            return True
138        if event.type() == event.Type.Leave:
139            self.hover_popup.schedule_hide()
140        return super().viewportEvent(event)
def show_hover( self, line: int, column: int, global_pos: PySide6.QtCore.QPoint, text_html: str):
142    def show_hover(self, line: int, column: int, global_pos: QPoint, text_html: str):
143        """Show a hover answer, unless the mouse has moved off that name."""
144        if self._hover_word != (line, column):
145            return
146        word = self.word_at(self.viewport().mapFromGlobal(QCursor.pos()))
147        if word is None or word[:2] != (line, column):
148            return
149        self.hover_popup.show_at(global_pos, text_html, (line, column))

Show a hover answer, unless the mouse has moved off that name.

def hide_popups(self):
151    def hide_popups(self):
152        self.hover_popup.hide()
153        self.completion_popup.hide()
154        self.signature_popup.hide()
def squiggle_spans( self, area: PySide6.QtCore.QRect) -> list[tuple[float, float, float, str]]:
156    def squiggle_spans(self, area: QRect) -> list[tuple[float, float, float, str]]:
157        """Where the misspelled words' squiggles go in ``area`` (viewport
158        coordinates): (left, right, y, color) per row a word is on."""
159        issues = self._spelling
160        if not issues:
161            return []
162        dark = self.palette().color(QPalette.Base).lightness() < 128
163        color = (DARK_UNDERLINE_COLORS if dark else MARKER_COLORS)["spelling"]
164        first = self.firstVisibleBlock()
165        top_line = first.blockNumber() + 1
166        offset = self.contentOffset()
167        out = []
168        # (Only the words in view: from the first visible line, until one is below the area.)
169        for issue in issues[bisect_left(self._spelling_lines, top_line):]:
170            block = self.document().findBlockByNumber(issue.line - 1)
171            geometry = self.blockBoundingGeometry(block).translated(offset)
172            if geometry.top() > area.bottom():
173                break
174            if geometry.bottom() < area.top() or not block.isVisible():
175                continue
176            span = self._lint_span(issue)
177            layout = block.layout()
178            if span is None or layout is None:
179                continue
180            start, end = span[0] - block.position(), span[1] - block.position()
181            for n in range(layout.lineCount()):
182                row = layout.lineAt(n)
183                a, b = max(start, row.textStart()), min(end, row.textStart() + row.textLength())
184                if a >= b:
185                    continue
186                x1, x2 = row.cursorToX(a), row.cursorToX(b)
187                x1 = x1[0] if isinstance(x1, tuple) else x1  # (PySide: (x, position))
188                x2 = x2[0] if isinstance(x2, tuple) else x2
189                y = geometry.top() + row.y() + row.ascent() + max(1.5, row.descent() * 0.45)
190                out.append((geometry.left() + x1, geometry.left() + x2, y, color))
191        return out

Where the misspelled words' squiggles go in area (viewport coordinates): (left, right, y, color) per row a word is on.