Coverage for vibefpmgui/command_palette.py: 96%
216 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"""View → Command Palette… (⌘⇧P / Ctrl+Shift+P): every command in the menus,
2found by typing part of its name, as in VS Code.
4The commands aren't listed here: they're the menu bar's actions, read when
5the palette opens (`collect`), so a command added to a menu is in the
6palette too. A menu that fills itself when it opens (Syntax Colors, Window
7Style, Recent Projects…) is asked to, as it is when it's opened. Running a
8command triggers its action, as choosing it in its menu does.
9"""
11from __future__ import annotations
13import re
14from typing import NamedTuple
16from qtpy.QtCore import QEvent, QRect, QSize, Qt, QTimer
17from qtpy.QtGui import QAction, QColor, QFontMetrics, QIcon, QKeySequence, QPalette
18from qtpy.QtWidgets import (
19 QApplication,
20 QFrame,
21 QLineEdit,
22 QListWidget,
23 QListWidgetItem,
24 QMenu,
25 QMenuBar,
26 QStyle,
27 QStyledItemDelegate,
28 QVBoxLayout,
29 QWidget,
30)
32SEPARATOR = " › "
33MAX_RECENT = 12
34COMMAND_ROLE = Qt.UserRole + 1 # an item's Command
37class Command(NamedTuple):
38 path: tuple[str, ...] # its menus' titles, then its own ("View", "Theme", "Nord")
39 action: QAction
41 @property
42 def name(self) -> str:
43 return self.path[-1]
45 @property
46 def title(self) -> str:
47 return SEPARATOR.join(self.path)
50def clean(text: str) -> str:
51 """A menu text as shown: no "&" for its mnemonic ("&&" is an "&"), no
52 tab and what's after it (a shortcut some menus put in their text)."""
53 text = text.split("\t", 1)[0]
54 return re.sub(r"&(.)", r"\1", text).strip()
57def collect(menu_bar: QMenuBar) -> list[Command]:
58 """Every command in the menu bar's menus, in menu order: not separators,
59 submenus themselves or hidden actions.
61 A submenu is found from its menu (``QMenu.menuAction()``), never with
62 ``QAction.menu()``: in PySide that leaves a menu the menu bar made (and
63 its actions) deleted as far as Python knows, which broke the app's own
64 menus."""
65 commands: list[Command] = []
66 seen: set[int] = set()
67 submenus = {m.menuAction(): m for m in menu_bar.window().findChildren(QMenu)}
69 def walk(menu: QMenu, path: tuple[str, ...]):
70 menu.aboutToShow.emit() # (a menu filled when it opens: filled now, as then)
71 submenus.update({m.menuAction(): m for m in menu.findChildren(QMenu)}) # (made by filling it)
72 for action in menu.actions():
73 if action.isSeparator() or not action.isVisible() or not clean(action.text()):
74 continue
75 here = (*path, clean(action.text()))
76 submenu = submenus.get(action)
77 if submenu is not None:
78 walk(submenu, here)
79 elif id(action) not in seen: 79 ↛ 72line 79 didn't jump to line 72 because the condition on line 79 was always true
80 seen.add(id(action))
81 commands.append(Command(here, action))
83 for action in menu_bar.actions():
84 submenu = submenus.get(action)
85 if submenu is not None and action.isVisible(): 85 ↛ 83line 85 didn't jump to line 83 because the condition on line 85 was always true
86 walk(submenu, (clean(action.text()),))
87 return commands
90def _word_starts(text: str) -> set[int]:
91 return {0} | {i for i in range(1, len(text)) if not text[i - 1].isalnum() and text[i].isalnum()}
94def _subsequence(word: str, text: str, starts: set[int], prefer: bool = True) -> int | None:
95 """How well ``word``'s letters appear, in order, in ``text`` (None: they
96 don't): each letter counts, more at a word's start or right after the
97 one before. Each letter goes at the next word's start that has it, if
98 any (``prefer``); if that leaves a later letter unmatched, at the next
99 place it is."""
100 score, at, last, first = 0, 0, -2, None
101 for ch in word:
102 found = text.find(ch, at)
103 if found < 0:
104 return None if prefer is False else _subsequence(word, text, starts, prefer=False)
105 if prefer and found != last + 1 and found not in starts:
106 later = found
107 while later >= 0 and later not in starts:
108 later = text.find(ch, later + 1)
109 if later >= 0:
110 found = later
111 gap = found - last - 1 if last >= 0 else 0
112 score += 1 + (3 if found in starts else 0) + (2 if found == last + 1 else 0) - min(gap, 4)
113 first = found if first is None else first
114 last, at = found, found + 1
115 if first is not None and last - first + 1 > max(3 * len(word), len(word) + 8):
116 return None # (its letters scattered far apart: not what was meant)
117 return score
120def score(query: str, command: Command) -> int | None:
121 """How well a command matches what's typed (None: it doesn't). Each word
122 typed must match, letters in order (so "mvup" finds Move Line Up); in
123 the command's own name counts more than in its menus' names."""
124 words = query.lower().split()
125 if not words:
126 return 0
127 name, title = command.name.lower(), command.title.lower()
128 name_starts, title_starts = _word_starts(name), _word_starts(title)
129 total = 0
130 for word in words:
131 in_name = _subsequence(word, name, name_starts)
132 in_title = _subsequence(word, title, title_starts)
133 if in_name is None and in_title is None:
134 return None
135 total += max(in_name * 2 if in_name is not None else 0, in_title or 0)
136 if name.startswith(word):
137 total += 5
138 if name.rstrip("…") == " ".join(words):
139 total += 10 # (just its name: it before others that start the same)
140 return total
143REVEAL_LETTERS = 5 # a hidden command's name: at least this much of it typed, to show it
146def revealed(query: str, command: Command) -> bool:
147 """Whether a hidden command shows for what's typed: the start of its name,
148 at least REVEAL_LETTERS letters of it (spaces and case aside)."""
149 typed = "".join(query.lower().split())
150 return len(typed) >= REVEAL_LETTERS and "".join(command.name.lower().split()).startswith(typed)
153def ranked(query: str, commands: list[Command], recent: list[str]) -> list[Command]:
154 """The commands matching ``query``, best first: the best match, then the
155 most recently used, then those available now (the others are listed,
156 greyed), then the fewest menus deep, then the shortest name, then menu
157 order. Nothing typed: the
158 recently used first, then the menus' order."""
159 order = {title: n for n, title in enumerate(recent)}
160 matched = []
161 for index, command in enumerate(commands):
162 points = score(query, command)
163 if points is not None:
164 matched.append((-points, order.get(command.title, len(recent)), not command.action.isEnabled(),
165 len(command.path), len(command.name), index, command))
166 matched.sort(key=lambda m: m[:6] if query.strip() else (m[1], m[2], m[5]))
167 return [m[-1] for m in matched]
170class _ItemDelegate(QStyledItemDelegate):
171 """A command: its menus' names muted, then its own; a tick if it's
172 checked; its shortcut on the right."""
174 def sizeHint(self, option, index):
175 return QSize(option.rect.width(), QFontMetrics(option.font).height() + 10)
177 def paint(self, painter, option, index):
178 command: Command = index.data(COMMAND_ROLE)
179 style = option.widget.style() if option.widget else QApplication.style()
180 style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget)
181 selected = bool(option.state & QStyle.State_Selected)
182 pal = option.palette
183 text = pal.color(QPalette.HighlightedText if selected else QPalette.Text)
184 muted = QColor(text)
185 muted.setAlpha(150)
186 if not command.action.isEnabled(): 186 ↛ 189line 186 didn't jump to line 189 because the condition on line 186 was always true
187 text.setAlpha(110)
188 muted.setAlpha(80)
189 fm = QFontMetrics(option.font)
190 rect = option.rect.adjusted(10, 0, -10, 0)
191 painter.save()
192 shortcut = command.action.shortcut().toString(QKeySequence.NativeText)
193 if shortcut: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 painter.setPen(muted)
195 painter.drawText(rect, Qt.AlignRight | Qt.AlignVCenter, shortcut)
196 rect.setRight(rect.right() - fm.horizontalAdvance(shortcut) - 16)
197 x = rect.left()
198 tick = "✓ " if command.action.isCheckable() and command.action.isChecked() else ""
199 menus = SEPARATOR.join(command.path[:-1]) + SEPARATOR if len(command.path) > 1 else ""
200 for part, color in ((tick, text), (menus, muted), (command.name, text)):
201 if not part:
202 continue
203 painter.setPen(color)
204 width = rect.right() - x
205 shown = fm.elidedText(part, Qt.ElideMiddle, max(0, width))
206 painter.drawText(QRect(x, rect.top(), max(0, width), rect.height()), Qt.AlignLeft | Qt.AlignVCenter,
207 shown)
208 x += fm.horizontalAdvance(shown)
209 painter.restore()
212class CommandPalette(QFrame):
213 """The palette: a field to type in, the matching commands under it.
214 ``recent`` (the titles of the commands last run, latest first) is kept
215 up to date in place; ``recent_changed`` (if set) is called after."""
217 WIDTH = 620
218 ROWS = 14
220 def __init__(self, window: QWidget, recent: list[str] | None = None):
221 super().__init__(window, Qt.Popup)
222 self.window_ = window
223 self.recent = recent if recent is not None else []
224 self.recent_changed = None
225 self.commands: list[Command] = []
226 self.hidden: list[Command] = [] # (in no menu: shown only when their name is typed, see revealed)
227 self.setFrameShape(QFrame.StyledPanel)
228 self.field = QLineEdit()
229 self.field.setPlaceholderText("Type a command (letters in order: \"mvup\" finds Move Line Up)")
230 self.field.setClearButtonEnabled(True)
231 self.field.textChanged.connect(self._filter)
232 self.field.installEventFilter(self)
233 # Its icon, in front of what's typed (the menu item's: see set_icon).
234 self.icon_action = self.field.addAction(QIcon(), QLineEdit.LeadingPosition)
235 self.list = QListWidget()
236 self.list.setItemDelegate(_ItemDelegate(self.list))
237 self.list.setUniformItemSizes(True)
238 self.list.setFocusPolicy(Qt.NoFocus)
239 self.list.itemClicked.connect(lambda item: self.run(item.data(COMMAND_ROLE)))
240 layout = QVBoxLayout(self)
241 layout.setContentsMargins(6, 6, 6, 6)
242 layout.setSpacing(6)
243 layout.addWidget(self.field)
244 layout.addWidget(self.list)
246 def set_icon(self, icon: QIcon):
247 """The icon in front of the field (the menu item's, in the theme's colors)."""
248 self.icon_action.setIcon(icon)
250 def open(self):
251 """Read the menus, and show the palette at the top of the window."""
252 menu_bar = self.window_.menuBar() if hasattr(self.window_, "menuBar") else None
253 self.commands = collect(menu_bar) if menu_bar is not None else []
254 self.field.clear()
255 self._filter("")
256 width = min(self.WIDTH, max(300, self.window_.width() - 40))
257 row = self.list.sizeHintForRow(0) if self.list.count() else 24
258 self.resize(width, self.field.sizeHint().height() + row * self.ROWS + 24)
259 top = self.window_.mapToGlobal(self.window_.rect().topLeft())
260 self.move(top.x() + (self.window_.width() - width) // 2, top.y() + 40)
261 self.show()
262 self.raise_()
263 self.field.setFocus()
265 def _filter(self, text: str):
266 self.list.clear()
267 shown = self.commands + [c for c in self.hidden if revealed(text, c)]
268 for command in ranked(text, shown, self.recent):
269 item = QListWidgetItem(command.title)
270 item.setData(COMMAND_ROLE, command)
271 if not command.action.isEnabled():
272 item.setToolTip("Not available now")
273 elif command.action.statusTip():
274 item.setToolTip(command.action.statusTip())
275 self.list.addItem(item)
276 if self.list.count():
277 self.list.setCurrentRow(0)
279 def eventFilter(self, obj, event):
280 if obj is self.field and event.type() == QEvent.KeyPress:
281 key = event.key()
282 steps = {Qt.Key_Up: -1, Qt.Key_Down: 1, Qt.Key_PageUp: -self.ROWS, Qt.Key_PageDown: self.ROWS}
283 if key in steps and self.list.count():
284 row = max(0, min(self.list.count() - 1, self.list.currentRow() + steps[key]))
285 self.list.setCurrentRow(row)
286 return True
287 if key in (Qt.Key_Return, Qt.Key_Enter):
288 item = self.list.currentItem()
289 if item is not None: 289 ↛ 291line 289 didn't jump to line 291 because the condition on line 289 was always true
290 self.run(item.data(COMMAND_ROLE))
291 return True
292 if key == Qt.Key_Escape:
293 self.close()
294 return True
295 return super().eventFilter(obj, event)
297 def run(self, command: Command):
298 """Run a command (if it's available now): the palette closes first, so
299 the command acts where the focus was (e.g. the editor)."""
300 if not command.action.isEnabled():
301 return
302 title = command.title
303 if title in self.recent: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 self.recent.remove(title)
305 self.recent.insert(0, title)
306 del self.recent[MAX_RECENT:]
307 if self.recent_changed is not None: 307 ↛ 309line 307 didn't jump to line 309 because the condition on line 307 was always true
308 self.recent_changed(self.recent)
309 self.close()
310 QTimer.singleShot(0, self.window_, command.action.trigger)
312 def current_titles(self) -> list[str]:
313 """(The titles listed, in order: for the tests.)"""
314 return [self.list.item(i).data(COMMAND_ROLE).title for i in range(self.list.count())]