Coverage for vibefpmgui/quick_open.py: 72%

157 statements  

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

1"""Go to File (⌘P / Ctrl+P): any file of the project, or of its dependencies, 

2found by typing part of its name, opened in the editor. 

3 

4The files are those the Project files tree lists (`ProjectTree.all_files`): 

5the ones ignored by git and the greyed-out ones only with its *Ignored* 

6ticked. What's typed matches the file's name first (its start, then 

7anywhere, then its letters in order: "dstat" finds demo_stats.f90), then 

8its path ("src/dem"); the project's own files before its dependencies', 

9then the shortest paths. Nothing typed: the files open in the editor first. 

10""" 

11 

12from __future__ import annotations 

13 

14from pathlib import Path, PurePosixPath 

15 

16from qtpy.QtCore import QEvent, QRect, QSize, Qt 

17from qtpy.QtGui import QFont, QFontMetrics, QIcon, QPalette 

18from qtpy.QtWidgets import ( 

19 QApplication, 

20 QFrame, 

21 QLabel, 

22 QLineEdit, 

23 QListWidget, 

24 QListWidgetItem, 

25 QStyle, 

26 QStyledItemDelegate, 

27 QVBoxLayout, 

28 QWidget, 

29) 

30 

31from .project_tree import ProjectFile 

32 

33FILE_ROLE = Qt.UserRole + 1 

34MAX_SHOWN = 300 # files listed at once (more: type more of the name) 

35 

36 

37def _in_order(letters: str, text: str) -> bool: 

38 """Whether ``letters`` appear in ``text`` in that order (not together).""" 

39 found = 0 

40 for ch in text: 

41 if found < len(letters) and ch == letters[found]: 

42 found += 1 

43 return found == len(letters) 

44 

45 

46def score(query: str, item: ProjectFile) -> int | None: 

47 """How well a file matches what's typed (None: not at all).""" 

48 typed = "".join(query.lower().split()) 

49 name, path = PurePosixPath(item.relative).name.lower(), item.relative.lower() 

50 if name.startswith(typed): 

51 return 1000 

52 if typed in name: 

53 return 800 

54 if _in_order(typed, name): 

55 return 600 

56 if typed in path: 

57 return 400 

58 if _in_order(typed, path): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 return 200 

60 return None 

61 

62 

63def ranked(query: str, files: list[ProjectFile], open_paths: list[str] | None = None) -> list[ProjectFile]: 

64 """The files matching ``query``, best first (see the module's description).""" 

65 open_order = {path: n for n, path in enumerate(open_paths or [])} 

66 if not query.strip(): 

67 return sorted(files, key=lambda f: (open_order.get(f.path, len(open_order)), f.dependency, f.package, 

68 f.relative.lower())) 

69 matched = [] 

70 for item in files: 

71 points = score(query, item) 

72 if points is not None: 

73 matched.append((-points, item.dependency, open_order.get(item.path, len(open_order)), 

74 len(item.relative), item.relative.lower(), item)) 

75 matched.sort(key=lambda m: m[:5]) 

76 return [m[-1] for m in matched] 

77 

78 

79class _FileDelegate(QStyledItemDelegate): 

80 """A file: its icon, name (bold), folder (muted) and, for a dependency's, 

81 the package.""" 

82 

83 def __init__(self, palette: QuickOpen): 

84 super().__init__(palette.list) 

85 self.palette_ = palette 

86 

87 def sizeHint(self, option, index): 

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

89 

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

91 item: ProjectFile = index.data(FILE_ROLE) 

92 style = option.widget.style() if option.widget else QApplication.style() 

93 style.drawPrimitive(QStyle.PE_PanelItemViewItem, option, painter, option.widget) 

94 selected = bool(option.state & QStyle.State_Selected) 

95 pal = option.palette 

96 text = pal.color(QPalette.HighlightedText if selected else QPalette.Text) 

97 muted = pal.color(QPalette.HighlightedText if selected else QPalette.PlaceholderText) 

98 rect = option.rect.adjusted(8, 0, -8, 0) 

99 painter.save() 

100 icon = self.palette_.icon_for(Path(item.path)) if self.palette_.icon_for else None 

101 size = QFontMetrics(option.font).height() 

102 x = rect.left() 

103 if icon is not None and not icon.isNull(): 

104 icon.paint(painter, QRect(x, rect.top() + (rect.height() - size) // 2, size, size)) 

105 x += size + 6 

106 bold = QFont(option.font) 

107 bold.setBold(True) 

108 name = PurePosixPath(item.relative).name 

109 folder = str(PurePosixPath(item.relative).parent) 

110 parts = [(name, bold, text), (f" {folder}" if folder != "." else "", option.font, muted)] 

111 if item.dependency: 

112 parts.append((f" · {item.package}", option.font, muted)) 

113 if item.hidden: 

114 parts.append((" · ignored", option.font, muted)) 

115 for part, font, color in parts: 

116 if not part or x >= rect.right(): 

117 continue 

118 painter.setFont(font) 

119 painter.setPen(color) 

120 metrics = QFontMetrics(font) 

121 shown = metrics.elidedText(part, Qt.ElideMiddle, rect.right() - x) 

122 painter.drawText(QRect(x, rect.top(), rect.right() - x, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, 

123 shown) 

124 x += metrics.horizontalAdvance(shown) 

125 painter.restore() 

126 

127 

128class QuickOpen(QFrame): 

129 """The palette: a field to type in, the matching files under it (as the 

130 Command Palette). ``opened`` is called with the file chosen.""" 

131 

132 WIDTH = 660 

133 ROWS = 14 

134 

135 def __init__(self, window: QWidget, opened, icon_for=None): 

136 super().__init__(window, Qt.Popup) 

137 self.window_ = window 

138 self.opened = opened 

139 self.icon_for = icon_for 

140 self.files: list[ProjectFile] = [] 

141 self.open_paths: list[str] = [] 

142 self.setFrameShape(QFrame.StyledPanel) 

143 self.field = QLineEdit() 

144 self.field.setPlaceholderText("Type part of a file's name (letters in order: \"dstat\" finds demo_stats.f90)") 

145 self.field.setClearButtonEnabled(True) 

146 self.field.textChanged.connect(self._filter) 

147 self.field.installEventFilter(self) 

148 # Its icon, in front of what's typed (the menu item's: see set_icon). 

149 self.icon_action = self.field.addAction(QIcon(), QLineEdit.LeadingPosition) 

150 self.list = QListWidget() 

151 self.list.setItemDelegate(_FileDelegate(self)) 

152 self.list.setUniformItemSizes(True) 

153 self.list.setFocusPolicy(Qt.NoFocus) 

154 self.list.itemClicked.connect(lambda item: self.choose(item.data(FILE_ROLE))) 

155 self.note = QLabel() # (whether ignored files are in the list, and how many more there are) 

156 self.note.setStyleSheet("color: gray;") 

157 layout = QVBoxLayout(self) 

158 layout.setContentsMargins(6, 6, 6, 6) 

159 layout.setSpacing(6) 

160 layout.addWidget(self.field) 

161 layout.addWidget(self.list) 

162 layout.addWidget(self.note) 

163 self.include_ignored = False 

164 

165 def set_icon(self, icon: QIcon): 

166 """The icon in front of the field (the menu item's, in the theme's colors).""" 

167 self.icon_action.setIcon(icon) 

168 

169 def open(self, files: list[ProjectFile], open_paths: list[str], include_ignored: bool): 

170 """Show the palette at the top of the window, for these files.""" 

171 self.files, self.open_paths, self.include_ignored = files, open_paths, include_ignored 

172 self.field.clear() 

173 self._filter("") 

174 width = min(self.WIDTH, max(320, self.window_.width() - 40)) 

175 row = self.list.sizeHintForRow(0) if self.list.count() else 24 

176 self.resize(width, self.field.sizeHint().height() + row * self.ROWS + self.note.sizeHint().height() + 30) 

177 top = self.window_.mapToGlobal(self.window_.rect().topLeft()) 

178 self.move(top.x() + (self.window_.width() - width) // 2, top.y() + 40) 

179 self.show() 

180 self.raise_() 

181 self.field.setFocus() 

182 

183 def _filter(self, text: str): 

184 self.list.clear() 

185 found = ranked(text, self.files, self.open_paths) 

186 for item in found[:MAX_SHOWN]: 

187 row = QListWidgetItem(item.relative) 

188 row.setData(FILE_ROLE, item) 

189 row.setToolTip(item.path) 

190 self.list.addItem(row) 

191 if self.list.count(): 191 ↛ 193line 191 didn't jump to line 193 because the condition on line 191 was always true

192 self.list.setCurrentRow(0) 

193 more = f"{len(found) - MAX_SHOWN} more: type more of the name. " if len(found) > MAX_SHOWN else "" 

194 which = ("Files ignored by git are included" if self.include_ignored 

195 else "Files ignored by git are left out") 

196 self.note.setText(f"{more}{which} (Project files → Ignored). {len(self.files)} files.") 

197 

198 def choose(self, item: ProjectFile): 

199 self.close() 

200 self.opened(item.path) 

201 

202 def eventFilter(self, obj, event): 

203 if obj is self.field and event.type() == QEvent.KeyPress: 

204 key = event.key() 

205 steps = {Qt.Key_Up: -1, Qt.Key_Down: 1, Qt.Key_PageUp: -self.ROWS, Qt.Key_PageDown: self.ROWS} 

206 if key in steps and self.list.count(): 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 self.list.setCurrentRow(max(0, min(self.list.count() - 1, self.list.currentRow() + steps[key]))) 

208 return True 

209 if key in (Qt.Key_Return, Qt.Key_Enter): 209 ↛ 214line 209 didn't jump to line 214 because the condition on line 209 was always true

210 item = self.list.currentItem() 

211 if item is not None: 211 ↛ 213line 211 didn't jump to line 213 because the condition on line 211 was always true

212 self.choose(item.data(FILE_ROLE)) 

213 return True 

214 if key == Qt.Key_Escape: 

215 self.close() 

216 return True 

217 return super().eventFilter(obj, event)