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
« 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.
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"""
12from __future__ import annotations
14from pathlib import Path, PurePosixPath
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)
31from .project_tree import ProjectFile
33FILE_ROLE = Qt.UserRole + 1
34MAX_SHOWN = 300 # files listed at once (more: type more of the name)
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)
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
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]
79class _FileDelegate(QStyledItemDelegate):
80 """A file: its icon, name (bold), folder (muted) and, for a dependency's,
81 the package."""
83 def __init__(self, palette: QuickOpen):
84 super().__init__(palette.list)
85 self.palette_ = palette
87 def sizeHint(self, option, index):
88 return QSize(option.rect.width(), QFontMetrics(option.font).height() + 10)
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()
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."""
132 WIDTH = 660
133 ROWS = 14
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
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)
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()
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.")
198 def choose(self, item: ProjectFile):
199 self.close()
200 self.opened(item.path)
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)