Coverage for vibefpmgui/completion.py: 82%
143 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"""Popups for the language server: the completion list and argument hints.
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"""
8from __future__ import annotations
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)
23from . import lsp as lsp_mod
25MAX_ROWS = 10
26ITEM_ROLE = Qt.UserRole + 1
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
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))
50class _KindDelegate(QStyledItemDelegate):
51 """Draws each completion's kind (function, variable…) greyed at the right."""
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()
68class CompletionPopup(QFrame):
69 """The list of completions for the name being typed."""
71 accepted = Signal(object) # the Completion chosen (double-click)
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)
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
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
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)
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()
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)))
165 def current(self) -> lsp_mod.Completion | None:
166 item = self.list.currentItem()
167 return item.data(ITEM_ROLE) if item else None
169 def page_rows(self) -> int:
170 return MAX_ROWS - 1
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."""
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
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()
201 def hideEvent(self, event):
202 self.line = None
203 super().hideEvent(event)
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())