vibefpmgui.completion
Popups for the language server: the completion list and argument hints.
Both are tooltip-style windows that never take the keyboard focus, so typing
continues in the editor; vibefpmgui.code_editor.CodeEditor passes them
the keys they use (Up/Down/Enter/Tab/Escape for the completion list).
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(): 44 y = anchor.top() - popup.height() - 2 45 elif not below and y < screen.top(): 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: 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): 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): 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: 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())
30def popup_colors(editor) -> tuple[QColor, QColor, QColor]: 31 """(background, text, border) matching the editor's colors.""" 32 pal = editor.palette() 33 base, text = pal.color(QPalette.Base), pal.color(QPalette.Text) 34 bg = base.lighter(115) if base.lightness() < 128 else base.darker(103) 35 border = QColor(text.red(), text.green(), text.blue(), 90) 36 return bg, text, border
(background, text, border) matching the editor's colors.
39def place(popup, anchor: QRect, below: bool = True): 40 """Put a popup just below (or above) a global rectangle, on screen.""" 41 screen = (QApplication.screenAt(anchor.center()) or QApplication.primaryScreen()).availableGeometry() 42 x = min(max(anchor.left(), screen.left()), screen.right() - popup.width()) 43 y = anchor.bottom() + 2 if below else anchor.top() - popup.height() - 2 44 if below and y + popup.height() > screen.bottom(): 45 y = anchor.top() - popup.height() - 2 46 elif not below and y < screen.top(): 47 y = anchor.bottom() + 2 48 popup.move(x, max(screen.top(), y))
Put a popup just below (or above) a global rectangle, on screen.
69class CompletionPopup(QFrame): 70 """The list of completions for the name being typed.""" 71 72 accepted = Signal(object) # the Completion chosen (double-click) 73 74 def __init__(self, editor): 75 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint) 76 self.editor = editor 77 self.setFrameShape(QFrame.Box) 78 self.items: list[lsp_mod.Completion] = [] 79 self.start: tuple[int, int] | None = None # (block number, column) the name starts at 80 self.list = QListWidget() 81 self.list.setFocusPolicy(Qt.NoFocus) 82 self.list.setFrameShape(QFrame.NoFrame) 83 self.list.setUniformItemSizes(True) 84 self.list.setItemDelegate(_KindDelegate(self.list)) 85 self.list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 86 self.list.currentRowChanged.connect(self._show_detail) 87 self.list.itemDoubleClicked.connect(lambda item: self.accepted.emit(item.data(ITEM_ROLE))) 88 self.detail = QLabel() 89 self.detail.setWordWrap(True) 90 self.detail.setTextFormat(Qt.PlainText) 91 self.detail.setContentsMargins(6, 3, 6, 4) 92 layout = QVBoxLayout(self) 93 layout.setContentsMargins(1, 1, 1, 1) 94 layout.setSpacing(0) 95 layout.addWidget(self.list) 96 layout.addWidget(self.detail) 97 98 def open(self, items: list[lsp_mod.Completion], start: tuple[int, int], prefix: str, 99 anchor: QRect) -> bool: 100 """Show ``items`` matching ``prefix``; False (and hidden) if none do.""" 101 self.items, self.start = items, start 102 bg, text, border = popup_colors(self.editor) 103 # The popup is never the active window, so give the selection the 104 # active colors explicitly (the inactive ones are hard to see). 105 pal = QApplication.palette() 106 sel, sel_text = pal.color(QPalette.Active, QPalette.Highlight), pal.color(QPalette.Active, 107 QPalette.HighlightedText) 108 self.setStyleSheet(f"CompletionPopup {{ background: {bg.name()}; border: 1px solid " 109 f"{border.name(QColor.HexArgb)}; }} QListWidget, QLabel {{ background: " 110 f"{bg.name()}; color: {text.name()}; }}" 111 f"QListWidget::item:selected {{ background: {sel.name()}; color: {sel_text.name()}; }}") 112 for widget in (self, self.list, self.detail): 113 widget.setFont(self.editor.font()) 114 if not self.filter(prefix): 115 return False 116 place(self, anchor) 117 self.show() 118 return True 119 120 def filter(self, prefix: str) -> bool: 121 """Show the items matching what has been typed (names starting with 122 it first, then names containing it); False if nothing matches.""" 123 lower = prefix.lower() 124 matches = [c for c in self.items if c.label.lower().startswith(lower)] 125 matches += [c for c in self.items if lower and lower in c.label.lower()[1:] 126 and not c.label.lower().startswith(lower)] 127 if not matches or (len(matches) == 1 and matches[0].label == prefix): 128 self.hide() # nothing to offer (or it's already typed out) 129 return False 130 self.list.clear() 131 for c in matches: 132 item = QListWidgetItem(c.label) 133 item.setData(ITEM_ROLE, c) 134 self.list.addItem(item) 135 self.list.setCurrentRow(0) 136 metrics = self.list.fontMetrics() 137 row_h = self.list.sizeHintForRow(0) 138 width = max(metrics.horizontalAdvance(c.label + " " + c.kind) for c in matches[:200]) + 40 139 width = min(max(width, 260), 560) 140 self.list.setFixedSize(QSize(width, row_h * min(MAX_ROWS, len(matches)) + 4)) 141 self.detail.setFixedWidth(width) 142 self._show_detail(0) 143 return True 144 145 def _fit(self): 146 """Size the popup to the list plus the detail line. (adjustSize() 147 only ever grows a window, so a shorter list left a gap.)""" 148 detail_h = self.detail.heightForWidth(self.detail.width()) if self.detail.isVisibleTo(self) else 0 149 self.detail.setFixedHeight(max(detail_h, 0)) 150 self.setFixedSize(self.list.width() + 2, self.list.height() + detail_h + 2) 151 152 def _show_detail(self, row: int): 153 item = self.list.item(row) 154 c: lsp_mod.Completion | None = item.data(ITEM_ROLE) if item else None 155 text = c.detail if c else "" 156 self.detail.setText(text) 157 self.detail.setVisible(bool(text)) 158 self._fit() 159 160 def move_selection(self, step: int): 161 rows = self.list.count() 162 if rows: 163 row = self.list.currentRow() + step 164 self.list.setCurrentRow(max(0, min(rows - 1, row))) 165 166 def current(self) -> lsp_mod.Completion | None: 167 item = self.list.currentItem() 168 return item.data(ITEM_ROLE) if item else None 169 170 def page_rows(self) -> int: 171 return MAX_ROWS - 1
The list of completions for the name being typed.
74 def __init__(self, editor): 75 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint) 76 self.editor = editor 77 self.setFrameShape(QFrame.Box) 78 self.items: list[lsp_mod.Completion] = [] 79 self.start: tuple[int, int] | None = None # (block number, column) the name starts at 80 self.list = QListWidget() 81 self.list.setFocusPolicy(Qt.NoFocus) 82 self.list.setFrameShape(QFrame.NoFrame) 83 self.list.setUniformItemSizes(True) 84 self.list.setItemDelegate(_KindDelegate(self.list)) 85 self.list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) 86 self.list.currentRowChanged.connect(self._show_detail) 87 self.list.itemDoubleClicked.connect(lambda item: self.accepted.emit(item.data(ITEM_ROLE))) 88 self.detail = QLabel() 89 self.detail.setWordWrap(True) 90 self.detail.setTextFormat(Qt.PlainText) 91 self.detail.setContentsMargins(6, 3, 6, 4) 92 layout = QVBoxLayout(self) 93 layout.setContentsMargins(1, 1, 1, 1) 94 layout.setSpacing(0) 95 layout.addWidget(self.list) 96 layout.addWidget(self.detail)
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, frameShape: PySide6.QtWidgets.QFrame.Shape | None = None, frameShadow: PySide6.QtWidgets.QFrame.Shadow | None = None, lineWidth: int | None = None, midLineWidth: int | None = None, frameWidth: int | None = None, frameRect: PySide6.QtCore.QRect | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
98 def open(self, items: list[lsp_mod.Completion], start: tuple[int, int], prefix: str, 99 anchor: QRect) -> bool: 100 """Show ``items`` matching ``prefix``; False (and hidden) if none do.""" 101 self.items, self.start = items, start 102 bg, text, border = popup_colors(self.editor) 103 # The popup is never the active window, so give the selection the 104 # active colors explicitly (the inactive ones are hard to see). 105 pal = QApplication.palette() 106 sel, sel_text = pal.color(QPalette.Active, QPalette.Highlight), pal.color(QPalette.Active, 107 QPalette.HighlightedText) 108 self.setStyleSheet(f"CompletionPopup {{ background: {bg.name()}; border: 1px solid " 109 f"{border.name(QColor.HexArgb)}; }} QListWidget, QLabel {{ background: " 110 f"{bg.name()}; color: {text.name()}; }}" 111 f"QListWidget::item:selected {{ background: {sel.name()}; color: {sel_text.name()}; }}") 112 for widget in (self, self.list, self.detail): 113 widget.setFont(self.editor.font()) 114 if not self.filter(prefix): 115 return False 116 place(self, anchor) 117 self.show() 118 return True
Show items matching prefix; False (and hidden) if none do.
120 def filter(self, prefix: str) -> bool: 121 """Show the items matching what has been typed (names starting with 122 it first, then names containing it); False if nothing matches.""" 123 lower = prefix.lower() 124 matches = [c for c in self.items if c.label.lower().startswith(lower)] 125 matches += [c for c in self.items if lower and lower in c.label.lower()[1:] 126 and not c.label.lower().startswith(lower)] 127 if not matches or (len(matches) == 1 and matches[0].label == prefix): 128 self.hide() # nothing to offer (or it's already typed out) 129 return False 130 self.list.clear() 131 for c in matches: 132 item = QListWidgetItem(c.label) 133 item.setData(ITEM_ROLE, c) 134 self.list.addItem(item) 135 self.list.setCurrentRow(0) 136 metrics = self.list.fontMetrics() 137 row_h = self.list.sizeHintForRow(0) 138 width = max(metrics.horizontalAdvance(c.label + " " + c.kind) for c in matches[:200]) + 40 139 width = min(max(width, 260), 560) 140 self.list.setFixedSize(QSize(width, row_h * min(MAX_ROWS, len(matches)) + 4)) 141 self.detail.setFixedWidth(width) 142 self._show_detail(0) 143 return True
Show the items matching what has been typed (names starting with it first, then names containing it); False if nothing matches.
174class SignaturePopup(QLabel): 175 """Argument hints for the call being typed: the routine's arguments with 176 the current one in bold, and that argument's declaration.""" 177 178 def __init__(self, editor): 179 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint) 180 self.editor = editor 181 self.setTextFormat(Qt.RichText) 182 self.setWordWrap(False) 183 self.setMargin(6) 184 self.line: int | None = None # the block the hint is for 185 186 def open(self, text_html: str, line: int, anchor: QRect): 187 bg, text, border = popup_colors(self.editor) 188 self.setStyleSheet(f"SignaturePopup {{ background: {bg.name()}; color: {text.name()}; " 189 f"border: 1px solid {border.name(QColor.HexArgb)}; }}") 190 self.setFont(self.editor.font()) 191 self.setText(text_html) 192 self.line = line 193 self.adjustSize() 194 screen = (QApplication.screenAt(anchor.center()) or QApplication.primaryScreen()).availableGeometry() 195 if self.width() > screen.width() * 0.8: 196 self.setWordWrap(True) 197 self.setFixedWidth(int(screen.width() * 0.8)) 198 self.adjustSize() 199 place(self, anchor, below=False) # above the line, so the completion list fits below 200 self.show() 201 202 def hideEvent(self, event): 203 self.line = None 204 super().hideEvent(event)
Argument hints for the call being typed: the routine's arguments with the current one in bold, and that argument's declaration.
178 def __init__(self, editor): 179 super().__init__(editor, Qt.ToolTip | Qt.FramelessWindowHint) 180 self.editor = editor 181 self.setTextFormat(Qt.RichText) 182 self.setWordWrap(False) 183 self.setMargin(6) 184 self.line: int | None = None # the block the hint is for
__init__(self, text: str, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None __init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, text: str | None = None, textFormat: PySide6.QtCore.Qt.TextFormat | None = None, pixmap: PySide6.QtGui.QPixmap | None = None, scaledContents: bool | None = None, alignment: PySide6.QtCore.Qt.AlignmentFlag | None = None, wordWrap: bool | None = None, margin: int | None = None, indent: int | None = None, openExternalLinks: bool | None = None, textInteractionFlags: PySide6.QtCore.Qt.TextInteractionFlag | None = None, hasSelectedText: bool | None = None, selectedText: str | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
186 def open(self, text_html: str, line: int, anchor: QRect): 187 bg, text, border = popup_colors(self.editor) 188 self.setStyleSheet(f"SignaturePopup {{ background: {bg.name()}; color: {text.name()}; " 189 f"border: 1px solid {border.name(QColor.HexArgb)}; }}") 190 self.setFont(self.editor.font()) 191 self.setText(text_html) 192 self.line = line 193 self.adjustSize() 194 screen = (QApplication.screenAt(anchor.center()) or QApplication.primaryScreen()).availableGeometry() 195 if self.width() > screen.width() * 0.8: 196 self.setWordWrap(True) 197 self.setFixedWidth(int(screen.width() * 0.8)) 198 self.adjustSize() 199 place(self, anchor, below=False) # above the line, so the completion list fits below 200 self.show()
207def global_rect(editor, cursor) -> QRect: 208 """The cursor's rectangle in screen coordinates.""" 209 rect = editor.cursorRect(cursor) 210 return QRect(editor.viewport().mapToGlobal(rect.topLeft()), rect.size())
The cursor's rectangle in screen coordinates.