Coverage for vibefpmgui/completion.py: 82%

143 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Popups for the language server: the completion list and argument hints. 

2 

3Both are tooltip-style windows that never take the keyboard focus, so typing 

4continues in the editor; `vibefpmgui.code_editor.CodeEditor` passes them 

5the keys they use (Up/Down/Enter/Tab/Escape for the completion list). 

6""" 

7 

8from __future__ import annotations 

9 

10from qtpy.QtCore import QRect, QSize, Qt, Signal 

11from qtpy.QtGui import QColor, QPalette 

12from qtpy.QtWidgets import ( 

13 QApplication, 

14 QFrame, 

15 QLabel, 

16 QListWidget, 

17 QListWidgetItem, 

18 QStyle, 

19 QStyledItemDelegate, 

20 QVBoxLayout, 

21) 

22 

23from . import lsp as lsp_mod 

24 

25MAX_ROWS = 10 

26ITEM_ROLE = Qt.UserRole + 1 

27 

28 

29def popup_colors(editor) -> tuple[QColor, QColor, QColor]: 

30 """(background, text, border) matching the editor's colors.""" 

31 pal = editor.palette() 

32 base, text = pal.color(QPalette.Base), pal.color(QPalette.Text) 

33 bg = base.lighter(115) if base.lightness() < 128 else base.darker(103) 

34 border = QColor(text.red(), text.green(), text.blue(), 90) 

35 return bg, text, border 

36 

37 

38def place(popup, anchor: QRect, below: bool = True): 

39 """Put a popup just below (or above) a global rectangle, on screen.""" 

40 screen = (QApplication.screenAt(anchor.center()) or QApplication.primaryScreen()).availableGeometry() 

41 x = min(max(anchor.left(), screen.left()), screen.right() - popup.width()) 

42 y = anchor.bottom() + 2 if below else anchor.top() - popup.height() - 2 

43 if below and y + popup.height() > screen.bottom(): 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true

44 y = anchor.top() - popup.height() - 2 

45 elif not below and y < screen.top(): 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 y = anchor.bottom() + 2 

47 popup.move(x, max(screen.top(), y)) 

48 

49 

50class _KindDelegate(QStyledItemDelegate): 

51 """Draws each completion's kind (function, variable…) greyed at the right.""" 

52 

53 def paint(self, painter, option, index): 

54 super().paint(painter, option, index) 

55 item: lsp_mod.Completion | None = index.data(ITEM_ROLE) 

56 if item is None or not item.kind: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 return 

58 painter.save() 

59 painter.setFont(option.font) 

60 color = option.palette.color(QPalette.HighlightedText if option.state & QStyle.State_Selected 

61 else QPalette.Text) 

62 color.setAlpha(140) 

63 painter.setPen(color) 

64 painter.drawText(option.rect.adjusted(0, 0, -8, 0), Qt.AlignRight | Qt.AlignVCenter, item.kind) 

65 painter.restore() 

66 

67 

68class CompletionPopup(QFrame): 

69 """The list of completions for the name being typed.""" 

70 

71 accepted = Signal(object) # the Completion chosen (double-click) 

72 

73 def __init__(self, editor): 

74 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint) 

75 self.editor = editor 

76 self.setFrameShape(QFrame.Box) 

77 self.items: list[lsp_mod.Completion] = [] 

78 self.start: tuple[int, int] | None = None # (block number, column) the name starts at 

79 self.list = QListWidget() 

80 self.list.setFocusPolicy(Qt.NoFocus) 

81 self.list.setFrameShape(QFrame.NoFrame) 

82 self.list.setUniformItemSizes(True) 

83 self.list.setItemDelegate(_KindDelegate(self.list)) 

84 self.list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 

85 self.list.currentRowChanged.connect(self._show_detail) 

86 self.list.itemDoubleClicked.connect(lambda item: self.accepted.emit(item.data(ITEM_ROLE))) 

87 self.detail = QLabel() 

88 self.detail.setWordWrap(True) 

89 self.detail.setTextFormat(Qt.PlainText) 

90 self.detail.setContentsMargins(6, 3, 6, 4) 

91 layout = QVBoxLayout(self) 

92 layout.setContentsMargins(1, 1, 1, 1) 

93 layout.setSpacing(0) 

94 layout.addWidget(self.list) 

95 layout.addWidget(self.detail) 

96 

97 def open(self, items: list[lsp_mod.Completion], start: tuple[int, int], prefix: str, 

98 anchor: QRect) -> bool: 

99 """Show ``items`` matching ``prefix``; False (and hidden) if none do.""" 

100 self.items, self.start = items, start 

101 bg, text, border = popup_colors(self.editor) 

102 # The popup is never the active window, so give the selection the 

103 # active colors explicitly (the inactive ones are hard to see). 

104 pal = QApplication.palette() 

105 sel, sel_text = pal.color(QPalette.Active, QPalette.Highlight), pal.color(QPalette.Active, 

106 QPalette.HighlightedText) 

107 self.setStyleSheet(f"CompletionPopup {{ background: {bg.name()}; border: 1px solid " 

108 f"{border.name(QColor.HexArgb)}; }} QListWidget, QLabel {{ background: " 

109 f"{bg.name()}; color: {text.name()}; }}" 

110 f"QListWidget::item:selected {{ background: {sel.name()}; color: {sel_text.name()}; }}") 

111 for widget in (self, self.list, self.detail): 

112 widget.setFont(self.editor.font()) 

113 if not self.filter(prefix): 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 return False 

115 place(self, anchor) 

116 self.show() 

117 return True 

118 

119 def filter(self, prefix: str) -> bool: 

120 """Show the items matching what has been typed (names starting with 

121 it first, then names containing it); False if nothing matches.""" 

122 lower = prefix.lower() 

123 matches = [c for c in self.items if c.label.lower().startswith(lower)] 

124 matches += [c for c in self.items if lower and lower in c.label.lower()[1:] 

125 and not c.label.lower().startswith(lower)] 

126 if not matches or (len(matches) == 1 and matches[0].label == prefix): 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true

127 self.hide() # nothing to offer (or it's already typed out) 

128 return False 

129 self.list.clear() 

130 for c in matches: 

131 item = QListWidgetItem(c.label) 

132 item.setData(ITEM_ROLE, c) 

133 self.list.addItem(item) 

134 self.list.setCurrentRow(0) 

135 metrics = self.list.fontMetrics() 

136 row_h = self.list.sizeHintForRow(0) 

137 width = max(metrics.horizontalAdvance(c.label + " " + c.kind) for c in matches[:200]) + 40 

138 width = min(max(width, 260), 560) 

139 self.list.setFixedSize(QSize(width, row_h * min(MAX_ROWS, len(matches)) + 4)) 

140 self.detail.setFixedWidth(width) 

141 self._show_detail(0) 

142 return True 

143 

144 def _fit(self): 

145 """Size the popup to the list plus the detail line. (adjustSize() 

146 only ever grows a window, so a shorter list left a gap.)""" 

147 detail_h = self.detail.heightForWidth(self.detail.width()) if self.detail.isVisibleTo(self) else 0 

148 self.detail.setFixedHeight(max(detail_h, 0)) 

149 self.setFixedSize(self.list.width() + 2, self.list.height() + detail_h + 2) 

150 

151 def _show_detail(self, row: int): 

152 item = self.list.item(row) 

153 c: lsp_mod.Completion | None = item.data(ITEM_ROLE) if item else None 

154 text = c.detail if c else "" 

155 self.detail.setText(text) 

156 self.detail.setVisible(bool(text)) 

157 self._fit() 

158 

159 def move_selection(self, step: int): 

160 rows = self.list.count() 

161 if rows: 161 ↛ exitline 161 didn't return from function 'move_selection' because the condition on line 161 was always true

162 row = self.list.currentRow() + step 

163 self.list.setCurrentRow(max(0, min(rows - 1, row))) 

164 

165 def current(self) -> lsp_mod.Completion | None: 

166 item = self.list.currentItem() 

167 return item.data(ITEM_ROLE) if item else None 

168 

169 def page_rows(self) -> int: 

170 return MAX_ROWS - 1 

171 

172 

173class SignaturePopup(QLabel): 

174 """Argument hints for the call being typed: the routine's arguments with 

175 the current one in bold, and that argument's declaration.""" 

176 

177 def __init__(self, editor): 

178 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint) 

179 self.editor = editor 

180 self.setTextFormat(Qt.RichText) 

181 self.setWordWrap(False) 

182 self.setMargin(6) 

183 self.line: int | None = None # the block the hint is for 

184 

185 def open(self, text_html: str, line: int, anchor: QRect): 

186 bg, text, border = popup_colors(self.editor) 

187 self.setStyleSheet(f"SignaturePopup {{ background: {bg.name()}; color: {text.name()}; " 

188 f"border: 1px solid {border.name(QColor.HexArgb)}; }}") 

189 self.setFont(self.editor.font()) 

190 self.setText(text_html) 

191 self.line = line 

192 self.adjustSize() 

193 screen = (QApplication.screenAt(anchor.center()) or QApplication.primaryScreen()).availableGeometry() 

194 if self.width() > screen.width() * 0.8: 

195 self.setWordWrap(True) 

196 self.setFixedWidth(int(screen.width() * 0.8)) 

197 self.adjustSize() 

198 place(self, anchor, below=False) # above the line, so the completion list fits below 

199 self.show() 

200 

201 def hideEvent(self, event): 

202 self.line = None 

203 super().hideEvent(event) 

204 

205 

206def global_rect(editor, cursor) -> QRect: 

207 """The cursor's rectangle in screen coordinates.""" 

208 rect = editor.cursorRect(cursor) 

209 return QRect(editor.viewport().mapToGlobal(rect.topLeft()), rect.size())