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

1"""The editor's hover card: a fortitude issue's or a word's (with its fixes), or fortls's hover.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING 

6 

7from qtpy.QtCore import QPoint, Qt, QTimer 

8from qtpy.QtGui import QColor, QCursor, QPalette 

9from qtpy.QtWidgets import QApplication, QFrame, QTextBrowser, QToolTip, QVBoxLayout 

10 

11if TYPE_CHECKING: 

12 from .code_editor import CodeEditor 

13 

14 

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.""" 

21 

22 MAX_WIDTH, MAX_HEIGHT = 720, 420 # also at most ~60% / 45% of the screen 

23 

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) 

49 

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

83 

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 

91 

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) 

118 

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

122 

123 def keep(self): 

124 self._hide_timer.stop() 

125 

126 def _hide_unless_hovered(self): 

127 if not self.geometry().contains(QCursor.pos()): 

128 self.hide() 

129 

130 def leaveEvent(self, event): 

131 self.schedule_hide() 

132 super().leaveEvent(event) 

133 

134 def enterEvent(self, event): 

135 self.keep() 

136 super().enterEvent(event)