Coverage for vibefpmgui/hover_popup.py: 75%
102 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"""The editor's hover card: a fortitude issue's or a word's (with its fixes), or fortls's hover."""
3from __future__ import annotations
5from typing import TYPE_CHECKING
7from qtpy.QtCore import QPoint, Qt, QTimer
8from qtpy.QtGui import QColor, QCursor, QPalette
9from qtpy.QtWidgets import QApplication, QFrame, QTextBrowser, QToolTip, QVBoxLayout
11if TYPE_CHECKING:
12 from .code_editor import CodeEditor
15class HoverPopup(QFrame):
16 """A tooltip-like popup for hover information that can scroll: fortls's
17 answer for a long declaration or doc comment can be taller than the
18 screen, and QToolTip can't scroll. It stays open while the mouse is on
19 the name or inside the popup (so it can be scrolled), and closes when
20 the mouse leaves both, on a key press, or when the editor scrolls."""
22 MAX_WIDTH, MAX_HEIGHT = 720, 420 # also at most ~60% / 45% of the screen
24 def __init__(self, editor: CodeEditor):
25 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint)
26 self.editor = editor
27 self.anchor = None # what the popup is about (see CodeEditor.hover_key)
28 # A rounded card: the window is transparent, the card inside is drawn.
29 self.setAttribute(Qt.WA_TranslucentBackground)
30 self.card = QFrame()
31 self.card.setObjectName("HoverCard")
32 self.browser = QTextBrowser()
33 self.browser.setFrameShape(QFrame.NoFrame)
34 self.browser.setOpenLinks(False)
35 self.browser.anchorClicked.connect(self._link_clicked)
36 self.browser.setFont(QToolTip.font())
37 self.browser.document().setDocumentMargin(9)
38 self.browser.viewport().setAutoFillBackground(False)
39 inner = QVBoxLayout(self.card)
40 inner.setContentsMargins(1, 3, 1, 3)
41 inner.addWidget(self.browser)
42 layout = QVBoxLayout(self)
43 layout.setContentsMargins(0, 0, 0, 0)
44 layout.addWidget(self.card)
45 self._hide_timer = QTimer(self)
46 self._hide_timer.setSingleShot(True)
47 self._hide_timer.setInterval(300) # time to move the mouse into the popup
48 self._hide_timer.timeout.connect(self._hide_unless_hovered)
50 def show_at(self, global_pos: QPoint, text_html: str, anchor):
51 self.anchor = anchor
52 self._hide_timer.stop()
53 # The editor's colors (Qt's tooltip colors don't follow dark mode on macOS).
54 pal = self.editor.palette()
55 base, text = pal.color(QPalette.Base), pal.color(QPalette.Text)
56 bg = base.lighter(115) if base.lightness() < 128 else base.darker(103)
57 border = QColor(text.red(), text.green(), text.blue(), 70).name(QColor.HexArgb)
58 self.setStyleSheet(f"#HoverCard {{ background: {bg.name()}; border: 1px solid {border}; "
59 f"border-radius: 9px; }} QTextBrowser {{ background: transparent; "
60 f"color: {text.name()}; }}")
61 self.browser.setHtml(text_html)
62 screen = (QApplication.screenAt(global_pos) or QApplication.primaryScreen()).availableGeometry()
63 max_w = min(self.MAX_WIDTH, int(screen.width() * 0.6))
64 max_h = min(self.MAX_HEIGHT, int(screen.height() * 0.45))
65 doc = self.browser.document()
66 doc.setTextWidth(-1)
67 bar = self.browser.verticalScrollBar().sizeHint().width()
68 width = min(int(doc.idealWidth()) + 4, max_w)
69 doc.setTextWidth(width)
70 height = int(doc.size().height()) + 10
71 if height > max_h: # it will scroll: leave room for the scroll bar 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 width = min(width + bar, max_w)
73 height = max_h
74 self.resize(width + 4, height + 8) # (the card's border and margins)
75 # Below the mouse; above it if there's no room; always on screen.
76 x = min(max(global_pos.x(), screen.left()), screen.right() - self.width())
77 y = global_pos.y() + 18
78 if y + self.height() > screen.bottom(): 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 y = max(screen.top(), global_pos.y() - 8 - self.height())
80 self.move(x, y)
81 self.browser.verticalScrollBar().setValue(0)
82 self.show()
84 def _link_clicked(self, url):
85 """The card's links: "Apply fix" (fix:N = the editor's Nth lint
86 issue), a spelling suggestion (spell:N:word) or "Add to Dictionary"
87 (addword:N); a web page (opened in the browser: a git blame card's
88 commit, issue, file), or copy:TEXT (to the clipboard)."""
89 if url.scheme() in ("http", "https"):
90 from .git_blame import open_web_page
92 self.hide()
93 open_web_page(url.toString())
94 return
95 if url.scheme() == "copy":
96 QApplication.clipboard().setText(url.path())
97 self.hide()
98 return
99 if url.scheme() in ("spell", "addword"): 99 ↛ 111line 99 didn't jump to line 111 because the condition on line 99 was always true
100 number, _, word = url.path().partition(":")
101 try:
102 issue = self.editor.lint_issues[int(number)]
103 except (ValueError, IndexError):
104 return
105 self.hide()
106 if url.scheme() == "spell": 106 ↛ 109line 106 didn't jump to line 109 because the condition on line 106 was always true
107 self.editor.replace_word(issue, word)
108 else:
109 self.editor.word_added.emit(issue.word)
110 return
111 if url.scheme() == "fix":
112 try:
113 issue = self.editor.lint_issues[int(url.path())]
114 except (ValueError, IndexError):
115 return
116 self.hide()
117 self.editor.lint_fix_requested.emit(issue)
119 def schedule_hide(self):
120 if self.isVisible() and not self._hide_timer.isActive(): 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true
121 self._hide_timer.start()
123 def keep(self):
124 self._hide_timer.stop()
126 def _hide_unless_hovered(self):
127 if not self.geometry().contains(QCursor.pos()):
128 self.hide()
130 def leaveEvent(self, event):
131 self.schedule_hide()
132 super().leaveEvent(event)
134 def enterEvent(self, event):
135 self.keep()
136 super().enterEvent(event)