vibefpmgui.result_tree
A list of places in files, grouped by file: the Search, References and Problems tabs.
Each file has a row with its icon, name, folder, package (for dependencies) and number of results; under it, a row per result with the line number and the line, in the colors the editor would give it, with the result's part highlighted (or, while replacing, struck through, with the replacement after it); for a problem, its line number, a colored badge (the rule or kind) and the message. Click a row to open the file there.
1"""A list of places in files, grouped by file: the *Search*, *References* 2and *Problems* tabs. 3 4Each file has a row with its icon, name, folder, package (for dependencies) 5and number of results; under it, a row per result with the line number and 6the line, in the colors the editor would give it, with the result's part 7highlighted (or, while replacing, struck through, with the replacement 8after it); for a problem, its line number, a colored badge (the rule or 9kind) and the message. Click a row to open the file there. 10""" 11 12from collections.abc import Callable 13from pathlib import Path 14from typing import NamedTuple 15 16from pygments import lex 17from pygments.styles import get_style_by_name 18from qtpy.QtCore import QRect, QSize, Qt, Signal 19from qtpy.QtGui import QColor, QFont, QFontMetrics, QPalette 20from qtpy.QtWidgets import ( 21 QApplication, 22 QStyle, 23 QStyledItemDelegate, 24 QStyleOptionViewItem, 25 QTreeWidget, 26 QTreeWidgetItem, 27) 28 29from . import highlighter as hl 30from .source_text import search_text 31 32PREVIEW_BEFORE = 50 # characters of the line shown before a result 33PREVIEW_AFTER = 200 34CONTEXT_LINES = 40 # lexed before a result's line, for its syntax colors 35 36PARTS_ROLE = Qt.UserRole + 1 # how a row is drawn (see _ResultDelegate) 37RESULT_ROLE = Qt.UserRole + 2 # (path, Match or None) 38 39MATCH_COLOR = QColor(242, 201, 76, 110) 40REMOVED_COLOR = QColor(255, 107, 107, 90) 41ADDED_COLOR = QColor(64, 192, 87, 100) 42 43 44class Root(NamedTuple): 45 """A package folder (the project's, or a dependency's).""" 46 name: str 47 directory: Path 48 dependency: bool 49 50 51class Match(NamedTuple): 52 """A place in a file, with the text shown for it.""" 53 line: int # 1-based 54 column: int # 0-based 55 end_line: int 56 end_column: int 57 before: str # the line's text around the match, for the results list 58 text: str # (the matched part of the first line) 59 after: str 60 start: int = 0 # the column ``before`` starts at (after any "…") 61 62 63class Problem(NamedTuple): 64 """An issue in a file (a compiler message, fortitude, fortls, spelling, 65 AI review), for the Problems tab.""" 66 line: int # 1-based 67 column: int # 0-based 68 end_line: int 69 end_column: int 70 severity: str # e.g. "error", "lint", "spelling" (see editor.MARKER_COLORS) 71 badge: str # shown in the colored badge: the rule code or the kind 72 message: str 73 color: str # the badge's color 74 source: str # which tool: "build", "fortitude", "fortls", "spelling", "review" 75 issue: object = None # fortitude's issue (a lint.LintIssue), with its fix if it has one 76 77 78def make_match(line_text: str, line: int, column: int, end_line: int, end_column: int) -> Match: 79 """A Match for a place on a line (the preview is cut from the line).""" 80 end = end_column if end_line == line else len(line_text) 81 before = line_text[:column].lstrip() 82 first = column - len(before) 83 if len(before) > PREVIEW_BEFORE: 84 before, first = "…" + before[-PREVIEW_BEFORE:], column - PREVIEW_BEFORE 85 return Match(line, column, end_line, end_column, before, line_text[column:end][:PREVIEW_AFTER], 86 line_text[end:][:PREVIEW_AFTER], first) 87 88 89def root_for(path: Path, roots: list[Root]) -> Root | None: 90 """The package a file is in (the innermost, for a package inside another).""" 91 best, best_len = None, -1 92 for root in roots: 93 top = root.directory.resolve() 94 if top in path.parents and len(str(top)) > best_len: 95 best, best_len = root, len(str(top)) 96 return best 97 98 99def file_labels(path: Path, root: Root | None, project_dir: Path | None) -> tuple[str, str]: 100 """(folder, package) shown after a file's name: the project's files by 101 their folder in the project, a dependency's by its folder in the 102 dependency, with "· name" for the package.""" 103 base = root.directory.resolve() if root is not None else None 104 if root is not None and not root.dependency and project_dir is not None: 105 base = project_dir.resolve() 106 folder = str(path.parent) 107 if base is not None: 108 try: 109 folder = path.parent.relative_to(base).as_posix() 110 except ValueError: 111 pass 112 folder = "" if folder == "." else folder 113 package = f"· {root.name}" if root is not None and root.dependency else "" 114 return folder, package 115 116 117def syntax_formats(lines: list[str], line: int, path: Path, style, formats: dict) -> list | None: 118 """The syntax format of each column of ``lines``' ``line`` (1-based; None: 119 plain), in a Pygments ``style``, lexed with the lines before it for 120 context (so a line inside a multi-line string or comment is colored as 121 one). ``formats``: token type -> format, kept between calls.""" 122 if not 0 < line <= len(lines): 123 return None 124 limit = 4000 # (long lines: only their start is shown anyway) 125 context = [s[:limit] for s in lines[max(0, line - 1 - CONTEXT_LINES):line]] 126 chunk = "\n".join(context) 127 offset = len(chunk) - len(context[-1]) 128 lexer = hl.lexer_for_path(path) # (it keeps the positions as they are) 129 result = [None] * len(context[-1]) 130 pos = 0 131 try: 132 for ttype, value in lex(chunk, lexer): 133 end = pos + len(value) 134 if end > offset: 135 fmt = formats.get(ttype) 136 if fmt is None: 137 fmt = formats[ttype] = hl.token_format(style, ttype) 138 for i in range(max(pos, offset), min(end, len(chunk))): 139 result[i - offset] = fmt 140 pos = end 141 except Exception: # noqa: BLE001 - never let highlighting break a list 142 return None 143 return result 144 145 146def paint_code(painter, rect: QRect, code: str, formats: list | None, column: int, font: QFont, 147 plain: QColor) -> int: 148 """Draw a line of code from ``rect``'s left, a run of one syntax color at 149 a time (``formats``: each column's format, None: plain; ``code`` starts at 150 the line's ``column``), clipped to ``rect``; the x it ended at.""" 151 x, start = rect.left(), 0 152 for i in range(1, len(code) + 1): 153 fmt = formats[column + start] if formats and column + start < len(formats) else None 154 if i < len(code) and formats and column + i < len(formats) and formats[column + i] is fmt: 155 continue 156 run = QFont(font) 157 if fmt is not None: 158 run.setBold(fmt.fontWeight() >= QFont.Bold) 159 run.setItalic(fmt.fontItalic()) 160 brush = fmt.foreground() if fmt is not None else None 161 painter.setFont(run) 162 painter.setPen(brush.color() if brush is not None and brush.style() != Qt.NoBrush else plain) 163 piece = code[start:i] 164 width = QFontMetrics(run).horizontalAdvance(piece) 165 painter.drawText(QRect(x, rect.top(), width, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, piece) 166 x += width 167 if x > rect.right(): 168 break 169 start = i 170 return x 171 172 173def _badge_font(font: QFont) -> QFont: 174 """The small bold font of a problem's badge.""" 175 small = QFont(font) 176 small.setPointSizeF(max(6.0, small.pointSizeF() - 2)) 177 small.setBold(True) 178 return small 179 180 181class _ResultDelegate(QStyledItemDelegate): 182 """Draws a file row (icon, name, folder, package, count) or a result 183 row (line number and the line, with the result highlighted, or the 184 replacement shown when replacing).""" 185 186 def __init__(self, tree: "ResultTree"): 187 super().__init__(tree) 188 self.tree = tree 189 190 def sizeHint(self, option, index): 191 size = super().sizeHint(option, index) 192 line_height = QFontMetrics(self.tree.mono).height() + 6 193 parts = index.data(PARTS_ROLE) 194 if parts is not None and parts[0] == "problem": # (the message wraps: as tall as it needs) 195 # Where the row starts: indented one step per level (plus one for the root's arrows). 196 depth, parent = 0, index.parent() 197 while parent.isValid(): 198 depth, parent = depth + 1, parent.parent() 199 left = self.tree.indentation() * (depth + int(self.tree.rootIsDecorated())) 200 width = max(80, self.tree.viewport().width() - left - 2) 201 _badge, message = self._problem_layout(option.font, parts[2], QRect(0, 0, width, line_height)) 202 height = QFontMetrics(option.font).boundingRect( 203 message, Qt.TextWordWrap | Qt.AlignLeft, parts[2].message).height() 204 return QSize(0, max(line_height, height + 6)) # (no width of its own: it wraps to the view's) 205 return QSize(size.width(), max(size.height(), line_height)) 206 207 def _problem_layout(self, font: QFont, problem, rect: QRect) -> tuple[QRect, QRect]: 208 """Where a problem row's badge goes (beside its line number, on the 209 first line) and the rectangle its message wraps in.""" 210 number = QFontMetrics(self.tree.mono).horizontalAdvance(f"{problem.line:>5} ") 211 width = QFontMetrics(_badge_font(font)).horizontalAdvance(problem.badge) + 10 212 first = QFontMetrics(self.tree.mono).height() + 6 # (the first line's height) 213 badge = QRect(rect.left() + 4 + number, rect.top() + 3, width, first - 6) 214 message = QRect(badge.right() + 9, rect.top() + 3, max(20, rect.right() - badge.right() - 13), 215 max(first - 6, rect.height() - 6)) 216 return badge, message 217 218 def paint(self, painter, option, index): 219 parts = index.data(PARTS_ROLE) 220 if parts is None: 221 super().paint(painter, option, index) 222 return 223 opt = QStyleOptionViewItem(option) 224 self.initStyleOption(opt, index) 225 opt.text = "" 226 style = opt.widget.style() if opt.widget else QApplication.style() 227 style.drawControl(QStyle.CE_ItemViewItem, opt, painter, opt.widget) 228 selected = bool(opt.state & QStyle.State_Selected) 229 group = QPalette.Active if opt.state & QStyle.State_Active else QPalette.Inactive 230 color = opt.palette.color(group, QPalette.HighlightedText if selected else QPalette.Text) 231 dim = QColor(color) 232 dim.setAlpha(140) 233 rect = opt.rect 234 x = rect.left() + 4 235 painter.save() 236 painter.setClipRect(rect) 237 238 def draw(text, font, pen, background=None, strike=False, formats=None, column=0): 239 """Draw text from x on; ``formats``: the syntax colors of the 240 line's columns (``text`` starts at ``column``).""" 241 nonlocal x 242 if not text: 243 return 244 runs = [(text, None)] 245 if formats: # split where the syntax color changes 246 runs, start = [], 0 247 fmt_at = [formats[column + i] if column + i < len(formats) else None 248 for i in range(len(text))] 249 for i in range(1, len(text) + 1): 250 if i == len(text) or fmt_at[i] is not fmt_at[start]: 251 runs.append((text[start:i], fmt_at[start])) 252 start = i 253 pieces = [] 254 for piece, fmt in runs: 255 f = QFont(font) 256 f.setStrikeOut(strike) 257 if fmt is not None: 258 f.setBold(fmt.fontWeight() >= QFont.Bold) 259 f.setItalic(fmt.fontItalic()) 260 pieces.append((piece, f, fmt, QFontMetrics(f).horizontalAdvance(piece))) 261 if background is not None: 262 width = sum(p[3] for p in pieces) 263 painter.fillRect(QRect(x, rect.top() + 2, width, rect.height() - 4), background) 264 for piece, f, fmt, width in pieces: 265 painter.setFont(f) 266 brush = fmt.foreground() if fmt is not None else None 267 painter.setPen(brush.color() if brush is not None and brush.style() != Qt.NoBrush 268 else pen) 269 painter.drawText(QRect(x, rect.top(), width, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, 270 piece) 271 x += width 272 273 if parts[0] == "file": 274 _kind, path, name, folder, package, count = parts 275 icon = self.tree.icon_for(path) if self.tree.icon_for else None 276 if icon is not None and not icon.isNull(): 277 size = QFontMetrics(opt.font).height() 278 icon.paint(painter, QRect(x, rect.top() + (rect.height() - size) // 2, size, size)) 279 x += size + 5 280 bold = QFont(opt.font) 281 bold.setBold(True) 282 draw(name, bold, color) 283 draw(f" {folder}" if folder else "", opt.font, dim) 284 draw(f" {package}" if package else "", opt.font, dim) 285 # The count at the right, in a small pill. 286 text = str(count) 287 w = QFontMetrics(opt.font).horizontalAdvance(text) + 12 288 pill = QRect(rect.right() - w - 6, rect.top() + 3, w, rect.height() - 6) 289 fill = QColor(color) 290 fill.setAlpha(40) 291 painter.setPen(Qt.NoPen) 292 painter.setBrush(fill) 293 painter.drawRoundedRect(pill, pill.height() / 2, pill.height() / 2) 294 painter.setPen(color) 295 painter.setFont(opt.font) 296 painter.drawText(pill, Qt.AlignCenter, text) 297 elif parts[0] == "problem": 298 _kind, path, problem = parts 299 badge, message = self._problem_layout(opt.font, problem, rect) 300 # The line number and badge on the first line; the message wraps beside them. 301 painter.setFont(self.tree.mono) 302 painter.setPen(dim) 303 painter.drawText(QRect(x, badge.top(), badge.left() - x, badge.height()), 304 Qt.AlignVCenter | Qt.AlignLeft, f"{problem.line:>5} ") 305 painter.setPen(Qt.NoPen) 306 painter.setBrush(QColor(problem.color)) 307 painter.drawRoundedRect(badge, 3, 3) 308 painter.setPen(QColor("white")) 309 painter.setFont(_badge_font(opt.font)) 310 painter.drawText(badge, Qt.AlignCenter, problem.badge) 311 painter.setPen(color) 312 painter.setFont(opt.font) 313 first = QFontMetrics(opt.font).height() 314 # (one line: centred on the badge, as before; more: from the top) 315 if QFontMetrics(opt.font).boundingRect(message, Qt.TextWordWrap, problem.message).height() <= first: 316 message.setTop(badge.top()) 317 message.setHeight(badge.height()) 318 painter.drawText(message, Qt.AlignVCenter | Qt.AlignLeft, problem.message) 319 else: 320 painter.drawText(message, Qt.TextWordWrap | Qt.AlignTop | Qt.AlignLeft, problem.message) 321 else: 322 _kind, path, found = parts 323 mono = self.tree.mono 324 # Syntax colors (none on a selected row: its text color reads best there). 325 formats = None if selected else self.tree.line_formats(path, found.line) 326 draw(f"{found.line:>5} ", mono, dim) 327 before = found.before 328 if before.startswith("…") and found.column - found.start < len(before): 329 draw("…", mono, dim) 330 before = before[1:] 331 draw(before, mono, color, formats=formats, column=found.start) 332 if self.tree.replacement is not None: 333 draw(found.text, mono, color, REMOVED_COLOR, strike=True, formats=formats, 334 column=found.column) 335 draw(self.tree.replacement(found.text), mono, color, ADDED_COLOR) 336 else: 337 draw(found.text, mono, color, MATCH_COLOR, formats=formats, column=found.column) 338 draw(found.after, mono, color, formats=formats, column=found.column + len(found.text)) 339 painter.restore() 340 341 342class ResultTree(QTreeWidget): 343 """Results grouped by file (see the module's description).""" 344 345 open_requested = Signal(str, int, int, int, int) # file, line, column, end line, end column 346 keep_requested = Signal(str) # a result double-clicked: its file, to keep open (not a preview) 347 348 def __init__(self, parent=None): 349 super().__init__(parent) 350 self.mono = QFont() # for the lines (set by the window) 351 self.icon_for: Callable[[Path], object] | None = None # a file's QIcon (set by the window) 352 self.text_for: Callable[[Path], str | None] | None = None # an open file's text (set by the window) 353 # While replacing: the replacement shown for a result's text. 354 self.replacement: Callable[[str], str] | None = None 355 self.items: dict[Path, QTreeWidgetItem] = {} 356 self._style = get_style_by_name(hl.LIGHT_STYLE) # (see refresh_style) 357 self._formats: dict = {} # token type -> format 358 self._file_lines: dict[Path, list[str]] = {} 359 self._line_formats: dict[tuple[Path, int], list | None] = {} 360 self.setHeaderHidden(True) 361 self.setUniformRowHeights(True) 362 self.setItemDelegate(_ResultDelegate(self)) 363 self.itemClicked.connect(self.open_item) 364 self.itemActivated.connect(self.open_item) 365 self.itemDoubleClicked.connect( 366 lambda item, _c: (data := item.data(0, RESULT_ROLE)) and self.keep_requested.emit(str(data[0]))) 367 368 # ------------------------------------------------------------ rows 369 def set_file(self, path: Path, matches: list[Match], folder: str = "", package: str = "", 370 expanded: bool | None = None) -> QTreeWidgetItem: 371 """Add a file's row (at the end) with its results, or fill it again.""" 372 item = self.items.get(path) 373 new = item is None 374 if new: 375 item = self.items[path] = QTreeWidgetItem() 376 self.addTopLevelItem(item) 377 was_expanded = item.isExpanded() 378 item.takeChildren() 379 item.setText(0, f"{folder}/{path.name}" if folder else path.name) 380 item.setToolTip(0, str(path)) 381 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(matches))) 382 item.setData(0, RESULT_ROLE, (str(path), None)) 383 for found in matches: 384 child = QTreeWidgetItem([f"{found.line}: {found.before}{found.text}{found.after}"]) 385 child.setToolTip(0, f"{path}:{found.line}") 386 child.setData(0, PARTS_ROLE, ("match", path, found)) 387 child.setData(0, RESULT_ROLE, (str(path), found)) 388 item.addChild(child) 389 item.setExpanded(expanded if expanded is not None else (True if new else was_expanded)) 390 return item 391 392 def set_problems(self, path: Path, problems: list[Problem], folder: str = "", package: str = ""): 393 """Show a file's problems (instead of whatever was shown), keeping 394 the scroll position when it's the same file again.""" 395 scroll = self.verticalScrollBar().value() if path in self.items else 0 396 self.clear() 397 self.items.clear() 398 self.forget(path) 399 item = self.items[path] = QTreeWidgetItem() 400 self.addTopLevelItem(item) 401 item.setText(0, path.name) 402 item.setToolTip(0, str(path)) 403 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(problems))) 404 item.setData(0, RESULT_ROLE, (str(path), None)) 405 for problem in problems: 406 child = QTreeWidgetItem([f"{problem.line}: {problem.badge} {problem.message}"]) 407 lines = self.file_lines(path) 408 code = lines[problem.line - 1].strip() if 0 < problem.line <= len(lines) else "" 409 child.setToolTip(0, f"{problem.message}\n\nLine {problem.line}: {code[:200]}") 410 child.setData(0, PARTS_ROLE, ("problem", path, problem)) 411 child.setData(0, RESULT_ROLE, (str(path), problem)) 412 item.addChild(child) 413 item.setExpanded(True) 414 self.verticalScrollBar().setValue(scroll) 415 416 def set_wrapping(self, on: bool): 417 """Rows as tall as their (wrapped) text: the Problems tab's messages 418 can be long (an AI review's). Off: one line each, quicker for long lists.""" 419 self.setUniformRowHeights(not on) 420 self.setWordWrap(on) 421 422 def resizeEvent(self, event): 423 super().resizeEvent(event) 424 if not self.uniformRowHeights() and event.size().width() != event.oldSize().width(): 425 self.doItemsLayout() # (wrapped rows: new heights for the new width) 426 427 def remove_file(self, path: Path): 428 item = self.items.pop(path, None) 429 if item is not None: 430 self.takeTopLevelItem(self.indexOfTopLevelItem(item)) 431 432 def clear_results(self): 433 self.clear() 434 self.items.clear() 435 self._file_lines.clear() 436 self._line_formats.clear() 437 438 def toggle_collapse(self) -> bool: 439 """Collapse every file, or expand them all if none is expanded. 440 Returns whether they are now expanded.""" 441 items = [self.topLevelItem(i) for i in range(self.topLevelItemCount())] 442 expand = not any(item.isExpanded() for item in items) 443 for item in items: 444 item.setExpanded(expand) 445 return expand 446 447 def open_item(self, item: QTreeWidgetItem, _column: int = 0): 448 """Open a result (a file's row: its first result).""" 449 data = item.data(0, RESULT_ROLE) 450 if not data: 451 return 452 path, found = data 453 if found is None: 454 if not item.childCount(): 455 return 456 found = item.child(0).data(0, RESULT_ROLE)[1] 457 self.open_requested.emit(path, found.line, found.column, found.end_line, found.end_column) 458 459 # ---------------------------------------------------------- text 460 def file_lines(self, path: Path) -> list[str]: 461 """A file's lines, as in the editor if it's open (kept for drawing).""" 462 lines = self._file_lines.get(path) 463 if lines is None: 464 text = self.text_for(path) if self.text_for is not None else None 465 if text is None: 466 text = search_text(path) or "" 467 lines = text.split("\n") 468 if len(self._file_lines) > 100: # (keep the most recent files) 469 self._file_lines.pop(next(iter(self._file_lines))) 470 self._file_lines[path] = lines 471 return lines 472 473 def forget(self, path: Path): 474 """A file changed: read and lex it again when its lines are drawn.""" 475 self._file_lines.pop(path, None) 476 for key in [k for k in self._line_formats if k[0] == path]: 477 del self._line_formats[key] 478 479 # -------------------------------------------------- syntax colors 480 def refresh_style(self): 481 """Use the editor's syntax colors (View → Syntax Colors), on its 482 background.""" 483 self._style = get_style_by_name(hl.style_name(QApplication.palette())) 484 self._formats.clear() 485 self._line_formats.clear() 486 palette = self.palette() 487 palette.setColor(QPalette.Base, QColor(self._style.background_color)) 488 palette.setColor(QPalette.Text, hl.style_text_color(self._style)) 489 self.setPalette(palette) 490 self.viewport().update() 491 492 def line_formats(self, path: Path, line: int) -> list | None: 493 """The syntax format of each column of a line (None: plain), lexed 494 with the lines before it for context (so a line inside a 495 multi-line string or comment is colored as one).""" 496 key = (path, line) 497 if key in self._line_formats: 498 return self._line_formats[key] 499 result = syntax_formats(self.file_lines(path), line, path, self._style, self._formats) 500 self._line_formats[key] = result 501 return result
45class Root(NamedTuple): 46 """A package folder (the project's, or a dependency's).""" 47 name: str 48 directory: Path 49 dependency: bool
A package folder (the project's, or a dependency's).
52class Match(NamedTuple): 53 """A place in a file, with the text shown for it.""" 54 line: int # 1-based 55 column: int # 0-based 56 end_line: int 57 end_column: int 58 before: str # the line's text around the match, for the results list 59 text: str # (the matched part of the first line) 60 after: str 61 start: int = 0 # the column ``before`` starts at (after any "…")
A place in a file, with the text shown for it.
64class Problem(NamedTuple): 65 """An issue in a file (a compiler message, fortitude, fortls, spelling, 66 AI review), for the Problems tab.""" 67 line: int # 1-based 68 column: int # 0-based 69 end_line: int 70 end_column: int 71 severity: str # e.g. "error", "lint", "spelling" (see editor.MARKER_COLORS) 72 badge: str # shown in the colored badge: the rule code or the kind 73 message: str 74 color: str # the badge's color 75 source: str # which tool: "build", "fortitude", "fortls", "spelling", "review" 76 issue: object = None # fortitude's issue (a lint.LintIssue), with its fix if it has one
An issue in a file (a compiler message, fortitude, fortls, spelling, AI review), for the Problems tab.
79def make_match(line_text: str, line: int, column: int, end_line: int, end_column: int) -> Match: 80 """A Match for a place on a line (the preview is cut from the line).""" 81 end = end_column if end_line == line else len(line_text) 82 before = line_text[:column].lstrip() 83 first = column - len(before) 84 if len(before) > PREVIEW_BEFORE: 85 before, first = "…" + before[-PREVIEW_BEFORE:], column - PREVIEW_BEFORE 86 return Match(line, column, end_line, end_column, before, line_text[column:end][:PREVIEW_AFTER], 87 line_text[end:][:PREVIEW_AFTER], first)
A Match for a place on a line (the preview is cut from the line).
90def root_for(path: Path, roots: list[Root]) -> Root | None: 91 """The package a file is in (the innermost, for a package inside another).""" 92 best, best_len = None, -1 93 for root in roots: 94 top = root.directory.resolve() 95 if top in path.parents and len(str(top)) > best_len: 96 best, best_len = root, len(str(top)) 97 return best
The package a file is in (the innermost, for a package inside another).
100def file_labels(path: Path, root: Root | None, project_dir: Path | None) -> tuple[str, str]: 101 """(folder, package) shown after a file's name: the project's files by 102 their folder in the project, a dependency's by its folder in the 103 dependency, with "· name" for the package.""" 104 base = root.directory.resolve() if root is not None else None 105 if root is not None and not root.dependency and project_dir is not None: 106 base = project_dir.resolve() 107 folder = str(path.parent) 108 if base is not None: 109 try: 110 folder = path.parent.relative_to(base).as_posix() 111 except ValueError: 112 pass 113 folder = "" if folder == "." else folder 114 package = f"· {root.name}" if root is not None and root.dependency else "" 115 return folder, package
(folder, package) shown after a file's name: the project's files by their folder in the project, a dependency's by its folder in the dependency, with "· name" for the package.
118def syntax_formats(lines: list[str], line: int, path: Path, style, formats: dict) -> list | None: 119 """The syntax format of each column of ``lines``' ``line`` (1-based; None: 120 plain), in a Pygments ``style``, lexed with the lines before it for 121 context (so a line inside a multi-line string or comment is colored as 122 one). ``formats``: token type -> format, kept between calls.""" 123 if not 0 < line <= len(lines): 124 return None 125 limit = 4000 # (long lines: only their start is shown anyway) 126 context = [s[:limit] for s in lines[max(0, line - 1 - CONTEXT_LINES):line]] 127 chunk = "\n".join(context) 128 offset = len(chunk) - len(context[-1]) 129 lexer = hl.lexer_for_path(path) # (it keeps the positions as they are) 130 result = [None] * len(context[-1]) 131 pos = 0 132 try: 133 for ttype, value in lex(chunk, lexer): 134 end = pos + len(value) 135 if end > offset: 136 fmt = formats.get(ttype) 137 if fmt is None: 138 fmt = formats[ttype] = hl.token_format(style, ttype) 139 for i in range(max(pos, offset), min(end, len(chunk))): 140 result[i - offset] = fmt 141 pos = end 142 except Exception: # noqa: BLE001 - never let highlighting break a list 143 return None 144 return result
The syntax format of each column of lines' line (1-based; None:
plain), in a Pygments style, lexed with the lines before it for
context (so a line inside a multi-line string or comment is colored as
one). formats: token type -> format, kept between calls.
147def paint_code(painter, rect: QRect, code: str, formats: list | None, column: int, font: QFont, 148 plain: QColor) -> int: 149 """Draw a line of code from ``rect``'s left, a run of one syntax color at 150 a time (``formats``: each column's format, None: plain; ``code`` starts at 151 the line's ``column``), clipped to ``rect``; the x it ended at.""" 152 x, start = rect.left(), 0 153 for i in range(1, len(code) + 1): 154 fmt = formats[column + start] if formats and column + start < len(formats) else None 155 if i < len(code) and formats and column + i < len(formats) and formats[column + i] is fmt: 156 continue 157 run = QFont(font) 158 if fmt is not None: 159 run.setBold(fmt.fontWeight() >= QFont.Bold) 160 run.setItalic(fmt.fontItalic()) 161 brush = fmt.foreground() if fmt is not None else None 162 painter.setFont(run) 163 painter.setPen(brush.color() if brush is not None and brush.style() != Qt.NoBrush else plain) 164 piece = code[start:i] 165 width = QFontMetrics(run).horizontalAdvance(piece) 166 painter.drawText(QRect(x, rect.top(), width, rect.height()), Qt.AlignVCenter | Qt.AlignLeft, piece) 167 x += width 168 if x > rect.right(): 169 break 170 start = i 171 return x
Draw a line of code from rect's left, a run of one syntax color at
a time (formats: each column's format, None: plain; code starts at
the line's column), clipped to rect; the x it ended at.
343class ResultTree(QTreeWidget): 344 """Results grouped by file (see the module's description).""" 345 346 open_requested = Signal(str, int, int, int, int) # file, line, column, end line, end column 347 keep_requested = Signal(str) # a result double-clicked: its file, to keep open (not a preview) 348 349 def __init__(self, parent=None): 350 super().__init__(parent) 351 self.mono = QFont() # for the lines (set by the window) 352 self.icon_for: Callable[[Path], object] | None = None # a file's QIcon (set by the window) 353 self.text_for: Callable[[Path], str | None] | None = None # an open file's text (set by the window) 354 # While replacing: the replacement shown for a result's text. 355 self.replacement: Callable[[str], str] | None = None 356 self.items: dict[Path, QTreeWidgetItem] = {} 357 self._style = get_style_by_name(hl.LIGHT_STYLE) # (see refresh_style) 358 self._formats: dict = {} # token type -> format 359 self._file_lines: dict[Path, list[str]] = {} 360 self._line_formats: dict[tuple[Path, int], list | None] = {} 361 self.setHeaderHidden(True) 362 self.setUniformRowHeights(True) 363 self.setItemDelegate(_ResultDelegate(self)) 364 self.itemClicked.connect(self.open_item) 365 self.itemActivated.connect(self.open_item) 366 self.itemDoubleClicked.connect( 367 lambda item, _c: (data := item.data(0, RESULT_ROLE)) and self.keep_requested.emit(str(data[0]))) 368 369 # ------------------------------------------------------------ rows 370 def set_file(self, path: Path, matches: list[Match], folder: str = "", package: str = "", 371 expanded: bool | None = None) -> QTreeWidgetItem: 372 """Add a file's row (at the end) with its results, or fill it again.""" 373 item = self.items.get(path) 374 new = item is None 375 if new: 376 item = self.items[path] = QTreeWidgetItem() 377 self.addTopLevelItem(item) 378 was_expanded = item.isExpanded() 379 item.takeChildren() 380 item.setText(0, f"{folder}/{path.name}" if folder else path.name) 381 item.setToolTip(0, str(path)) 382 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(matches))) 383 item.setData(0, RESULT_ROLE, (str(path), None)) 384 for found in matches: 385 child = QTreeWidgetItem([f"{found.line}: {found.before}{found.text}{found.after}"]) 386 child.setToolTip(0, f"{path}:{found.line}") 387 child.setData(0, PARTS_ROLE, ("match", path, found)) 388 child.setData(0, RESULT_ROLE, (str(path), found)) 389 item.addChild(child) 390 item.setExpanded(expanded if expanded is not None else (True if new else was_expanded)) 391 return item 392 393 def set_problems(self, path: Path, problems: list[Problem], folder: str = "", package: str = ""): 394 """Show a file's problems (instead of whatever was shown), keeping 395 the scroll position when it's the same file again.""" 396 scroll = self.verticalScrollBar().value() if path in self.items else 0 397 self.clear() 398 self.items.clear() 399 self.forget(path) 400 item = self.items[path] = QTreeWidgetItem() 401 self.addTopLevelItem(item) 402 item.setText(0, path.name) 403 item.setToolTip(0, str(path)) 404 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(problems))) 405 item.setData(0, RESULT_ROLE, (str(path), None)) 406 for problem in problems: 407 child = QTreeWidgetItem([f"{problem.line}: {problem.badge} {problem.message}"]) 408 lines = self.file_lines(path) 409 code = lines[problem.line - 1].strip() if 0 < problem.line <= len(lines) else "" 410 child.setToolTip(0, f"{problem.message}\n\nLine {problem.line}: {code[:200]}") 411 child.setData(0, PARTS_ROLE, ("problem", path, problem)) 412 child.setData(0, RESULT_ROLE, (str(path), problem)) 413 item.addChild(child) 414 item.setExpanded(True) 415 self.verticalScrollBar().setValue(scroll) 416 417 def set_wrapping(self, on: bool): 418 """Rows as tall as their (wrapped) text: the Problems tab's messages 419 can be long (an AI review's). Off: one line each, quicker for long lists.""" 420 self.setUniformRowHeights(not on) 421 self.setWordWrap(on) 422 423 def resizeEvent(self, event): 424 super().resizeEvent(event) 425 if not self.uniformRowHeights() and event.size().width() != event.oldSize().width(): 426 self.doItemsLayout() # (wrapped rows: new heights for the new width) 427 428 def remove_file(self, path: Path): 429 item = self.items.pop(path, None) 430 if item is not None: 431 self.takeTopLevelItem(self.indexOfTopLevelItem(item)) 432 433 def clear_results(self): 434 self.clear() 435 self.items.clear() 436 self._file_lines.clear() 437 self._line_formats.clear() 438 439 def toggle_collapse(self) -> bool: 440 """Collapse every file, or expand them all if none is expanded. 441 Returns whether they are now expanded.""" 442 items = [self.topLevelItem(i) for i in range(self.topLevelItemCount())] 443 expand = not any(item.isExpanded() for item in items) 444 for item in items: 445 item.setExpanded(expand) 446 return expand 447 448 def open_item(self, item: QTreeWidgetItem, _column: int = 0): 449 """Open a result (a file's row: its first result).""" 450 data = item.data(0, RESULT_ROLE) 451 if not data: 452 return 453 path, found = data 454 if found is None: 455 if not item.childCount(): 456 return 457 found = item.child(0).data(0, RESULT_ROLE)[1] 458 self.open_requested.emit(path, found.line, found.column, found.end_line, found.end_column) 459 460 # ---------------------------------------------------------- text 461 def file_lines(self, path: Path) -> list[str]: 462 """A file's lines, as in the editor if it's open (kept for drawing).""" 463 lines = self._file_lines.get(path) 464 if lines is None: 465 text = self.text_for(path) if self.text_for is not None else None 466 if text is None: 467 text = search_text(path) or "" 468 lines = text.split("\n") 469 if len(self._file_lines) > 100: # (keep the most recent files) 470 self._file_lines.pop(next(iter(self._file_lines))) 471 self._file_lines[path] = lines 472 return lines 473 474 def forget(self, path: Path): 475 """A file changed: read and lex it again when its lines are drawn.""" 476 self._file_lines.pop(path, None) 477 for key in [k for k in self._line_formats if k[0] == path]: 478 del self._line_formats[key] 479 480 # -------------------------------------------------- syntax colors 481 def refresh_style(self): 482 """Use the editor's syntax colors (View → Syntax Colors), on its 483 background.""" 484 self._style = get_style_by_name(hl.style_name(QApplication.palette())) 485 self._formats.clear() 486 self._line_formats.clear() 487 palette = self.palette() 488 palette.setColor(QPalette.Base, QColor(self._style.background_color)) 489 palette.setColor(QPalette.Text, hl.style_text_color(self._style)) 490 self.setPalette(palette) 491 self.viewport().update() 492 493 def line_formats(self, path: Path, line: int) -> list | None: 494 """The syntax format of each column of a line (None: plain), lexed 495 with the lines before it for context (so a line inside a 496 multi-line string or comment is colored as one).""" 497 key = (path, line) 498 if key in self._line_formats: 499 return self._line_formats[key] 500 result = syntax_formats(self.file_lines(path), line, path, self._style, self._formats) 501 self._line_formats[key] = result 502 return result
Results grouped by file (see the module's description).
349 def __init__(self, parent=None): 350 super().__init__(parent) 351 self.mono = QFont() # for the lines (set by the window) 352 self.icon_for: Callable[[Path], object] | None = None # a file's QIcon (set by the window) 353 self.text_for: Callable[[Path], str | None] | None = None # an open file's text (set by the window) 354 # While replacing: the replacement shown for a result's text. 355 self.replacement: Callable[[str], str] | None = None 356 self.items: dict[Path, QTreeWidgetItem] = {} 357 self._style = get_style_by_name(hl.LIGHT_STYLE) # (see refresh_style) 358 self._formats: dict = {} # token type -> format 359 self._file_lines: dict[Path, list[str]] = {} 360 self._line_formats: dict[tuple[Path, int], list | None] = {} 361 self.setHeaderHidden(True) 362 self.setUniformRowHeights(True) 363 self.setItemDelegate(_ResultDelegate(self)) 364 self.itemClicked.connect(self.open_item) 365 self.itemActivated.connect(self.open_item) 366 self.itemDoubleClicked.connect( 367 lambda item, _c: (data := item.data(0, RESULT_ROLE)) and self.keep_requested.emit(str(data[0])))
__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, *, columnCount: int | None = None, topLevelItemCount: int | None = None, supportedDragActions: PySide6.QtCore.Qt.DropAction | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
370 def set_file(self, path: Path, matches: list[Match], folder: str = "", package: str = "", 371 expanded: bool | None = None) -> QTreeWidgetItem: 372 """Add a file's row (at the end) with its results, or fill it again.""" 373 item = self.items.get(path) 374 new = item is None 375 if new: 376 item = self.items[path] = QTreeWidgetItem() 377 self.addTopLevelItem(item) 378 was_expanded = item.isExpanded() 379 item.takeChildren() 380 item.setText(0, f"{folder}/{path.name}" if folder else path.name) 381 item.setToolTip(0, str(path)) 382 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(matches))) 383 item.setData(0, RESULT_ROLE, (str(path), None)) 384 for found in matches: 385 child = QTreeWidgetItem([f"{found.line}: {found.before}{found.text}{found.after}"]) 386 child.setToolTip(0, f"{path}:{found.line}") 387 child.setData(0, PARTS_ROLE, ("match", path, found)) 388 child.setData(0, RESULT_ROLE, (str(path), found)) 389 item.addChild(child) 390 item.setExpanded(expanded if expanded is not None else (True if new else was_expanded)) 391 return item
Add a file's row (at the end) with its results, or fill it again.
393 def set_problems(self, path: Path, problems: list[Problem], folder: str = "", package: str = ""): 394 """Show a file's problems (instead of whatever was shown), keeping 395 the scroll position when it's the same file again.""" 396 scroll = self.verticalScrollBar().value() if path in self.items else 0 397 self.clear() 398 self.items.clear() 399 self.forget(path) 400 item = self.items[path] = QTreeWidgetItem() 401 self.addTopLevelItem(item) 402 item.setText(0, path.name) 403 item.setToolTip(0, str(path)) 404 item.setData(0, PARTS_ROLE, ("file", path, path.name, folder, package, len(problems))) 405 item.setData(0, RESULT_ROLE, (str(path), None)) 406 for problem in problems: 407 child = QTreeWidgetItem([f"{problem.line}: {problem.badge} {problem.message}"]) 408 lines = self.file_lines(path) 409 code = lines[problem.line - 1].strip() if 0 < problem.line <= len(lines) else "" 410 child.setToolTip(0, f"{problem.message}\n\nLine {problem.line}: {code[:200]}") 411 child.setData(0, PARTS_ROLE, ("problem", path, problem)) 412 child.setData(0, RESULT_ROLE, (str(path), problem)) 413 item.addChild(child) 414 item.setExpanded(True) 415 self.verticalScrollBar().setValue(scroll)
Show a file's problems (instead of whatever was shown), keeping the scroll position when it's the same file again.
417 def set_wrapping(self, on: bool): 418 """Rows as tall as their (wrapped) text: the Problems tab's messages 419 can be long (an AI review's). Off: one line each, quicker for long lists.""" 420 self.setUniformRowHeights(not on) 421 self.setWordWrap(on)
Rows as tall as their (wrapped) text: the Problems tab's messages can be long (an AI review's). Off: one line each, quicker for long lists.
423 def resizeEvent(self, event): 424 super().resizeEvent(event) 425 if not self.uniformRowHeights() and event.size().width() != event.oldSize().width(): 426 self.doItemsLayout() # (wrapped rows: new heights for the new width)
resizeEvent(self, event: PySide6.QtGui.QResizeEvent, /) -> None
439 def toggle_collapse(self) -> bool: 440 """Collapse every file, or expand them all if none is expanded. 441 Returns whether they are now expanded.""" 442 items = [self.topLevelItem(i) for i in range(self.topLevelItemCount())] 443 expand = not any(item.isExpanded() for item in items) 444 for item in items: 445 item.setExpanded(expand) 446 return expand
Collapse every file, or expand them all if none is expanded. Returns whether they are now expanded.
448 def open_item(self, item: QTreeWidgetItem, _column: int = 0): 449 """Open a result (a file's row: its first result).""" 450 data = item.data(0, RESULT_ROLE) 451 if not data: 452 return 453 path, found = data 454 if found is None: 455 if not item.childCount(): 456 return 457 found = item.child(0).data(0, RESULT_ROLE)[1] 458 self.open_requested.emit(path, found.line, found.column, found.end_line, found.end_column)
Open a result (a file's row: its first result).
461 def file_lines(self, path: Path) -> list[str]: 462 """A file's lines, as in the editor if it's open (kept for drawing).""" 463 lines = self._file_lines.get(path) 464 if lines is None: 465 text = self.text_for(path) if self.text_for is not None else None 466 if text is None: 467 text = search_text(path) or "" 468 lines = text.split("\n") 469 if len(self._file_lines) > 100: # (keep the most recent files) 470 self._file_lines.pop(next(iter(self._file_lines))) 471 self._file_lines[path] = lines 472 return lines
A file's lines, as in the editor if it's open (kept for drawing).
474 def forget(self, path: Path): 475 """A file changed: read and lex it again when its lines are drawn.""" 476 self._file_lines.pop(path, None) 477 for key in [k for k in self._line_formats if k[0] == path]: 478 del self._line_formats[key]
A file changed: read and lex it again when its lines are drawn.
481 def refresh_style(self): 482 """Use the editor's syntax colors (View → Syntax Colors), on its 483 background.""" 484 self._style = get_style_by_name(hl.style_name(QApplication.palette())) 485 self._formats.clear() 486 self._line_formats.clear() 487 palette = self.palette() 488 palette.setColor(QPalette.Base, QColor(self._style.background_color)) 489 palette.setColor(QPalette.Text, hl.style_text_color(self._style)) 490 self.setPalette(palette) 491 self.viewport().update()
Use the editor's syntax colors (View → Syntax Colors), on its background.
493 def line_formats(self, path: Path, line: int) -> list | None: 494 """The syntax format of each column of a line (None: plain), lexed 495 with the lines before it for context (so a line inside a 496 multi-line string or comment is colored as one).""" 497 key = (path, line) 498 if key in self._line_formats: 499 return self._line_formats[key] 500 result = syntax_formats(self.file_lines(path), line, path, self._style, self._formats) 501 self._line_formats[key] = result 502 return result
The syntax format of each column of a line (None: plain), lexed with the lines before it for context (so a line inside a multi-line string or comment is colored as one).