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

1"""View → Command Palette… (⌘⇧P / Ctrl+Shift+P): every command in the menus, 

2found by typing part of its name, as in VS Code. 

3 

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

10 

11from __future__ import annotations 

12 

13import re 

14from typing import NamedTuple 

15 

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) 

31 

32SEPARATOR = " › " 

33MAX_RECENT = 12 

34COMMAND_ROLE = Qt.UserRole + 1 # an item's Command 

35 

36 

37class Command(NamedTuple): 

38 path: tuple[str, ...] # its menus' titles, then its own ("View", "Theme", "Nord") 

39 action: QAction 

40 

41 @property 

42 def name(self) -> str: 

43 return self.path[-1] 

44 

45 @property 

46 def title(self) -> str: 

47 return SEPARATOR.join(self.path) 

48 

49 

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

55 

56 

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. 

60 

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

68 

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

82 

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 

88 

89 

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

92 

93 

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 

118 

119 

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 

141 

142 

143REVEAL_LETTERS = 5 # a hidden command's name: at least this much of it typed, to show it 

144 

145 

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) 

151 

152 

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] 

168 

169 

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

173 

174 def sizeHint(self, option, index): 

175 return QSize(option.rect.width(), QFontMetrics(option.font).height() + 10) 

176 

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

210 

211 

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

216 

217 WIDTH = 620 

218 ROWS = 14 

219 

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) 

245 

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) 

249 

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

264 

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) 

278 

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) 

296 

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) 

311 

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

315