vibefpmgui.complexity_panel

The Complexity tab: each procedure's complexity, as lizard measures it (see vibefpmgui.complexity): CCN, lines of code, tokens, parameters, length and nesting depth, the values over lizard's thresholds in red; each file's totals; and the selected procedure's control-flow graph (see vibefpmgui.control_flow, vibefpmgui.cfg_view). Click a procedure to open it; click a file to list only its procedures (its first one selected and drawn); click a node of the graph to open its lines.

  1"""The *Complexity* tab: each procedure's complexity, as lizard measures it
  2(see `vibefpmgui.complexity`): CCN, lines of code, tokens, parameters,
  3length and nesting depth, the values over lizard's thresholds in red; each
  4file's totals; and the selected procedure's control-flow graph (see
  5`vibefpmgui.control_flow`, `vibefpmgui.cfg_view`). Click a procedure to open
  6it; click a file to list only its procedures (its first one selected and
  7drawn); click a node of the graph to open its lines.
  8"""
  9
 10from __future__ import annotations
 11
 12import html
 13from pathlib import Path
 14from typing import NamedTuple
 15
 16from qtpy.QtCore import Qt, Signal
 17from qtpy.QtGui import QColor, QFont
 18from qtpy.QtWidgets import (
 19    QAbstractItemView,
 20    QApplication,
 21    QCheckBox,
 22    QHBoxLayout,
 23    QHeaderView,
 24    QLabel,
 25    QLineEdit,
 26    QSplitter,
 27    QVBoxLayout,
 28    QWidget,
 29)
 30
 31from . import complexity as cx
 32from . import control_flow as cf
 33from .build_monitor import PALETTES, TitleIcon, app_is_light, panel_stylesheet
 34from .cfg_view import CfgView
 35from .icons import symbol_icon
 36from .metrics_table import Column, MetricsTable, RowStyle
 37from .source_text import read_source
 38from .widgets import StatusLabel
 39
 40OVER = "#e03131"  # a value over its threshold
 41PROCEDURE_COLUMNS = ("FILE", "PROCEDURE", "CCN", "NLOC", "TOKENS", "PARAMS", "LENGTH", "DEPTH")
 42FILE_COLUMN, NAME_COLUMN = 0, 1  # (in the procedures' table)
 43MAX_TEXT_WIDTH = 320  # a file's or procedure's column at most (wider: drag it; a long path elided at its start)
 44FILE_COLUMNS = ("FILE", "NLOC", "PROCEDURES", "AVG CCN", "MAX CCN", "OVER")
 45COLUMN_TIPS = {
 46    "CCN": "Cyclomatic complexity: 1 + its decisions (if, else if, loops, case, type is, where, .and., .or.)",
 47    "NLOC": "Lines of code (not blank, not only a comment)",
 48    "TOKENS": "Tokens in its code",
 49    "PARAMS": "Dummy arguments",
 50    "LENGTH": "Lines, first to last (with comments and its internal procedures)",
 51    "DEPTH": "How deeply its block constructs (do, if…then, select…) nest",
 52    "OVER": "Procedures with a value over its threshold",
 53}
 54METRIC_OF_COLUMN = {"CCN": "ccn", "PARAMS": "params", "LENGTH": "length", "DEPTH": "depth"}
 55PROCEDURE_ROLE = Qt.UserRole + 1  # a procedure's row: its complexity.Procedure
 56
 57
 58class ProcedureRow(NamedTuple):
 59    file: cx.FileMetrics
 60    proc: cx.Procedure
 61    label: str               # its file's label
 62    over: frozenset          # its metrics over their thresholds ("ccn"…)
 63
 64
 65class FileRow(NamedTuple):
 66    file: cx.FileMetrics
 67    label: str
 68    over: int                # its procedures over a threshold
 69    highest: int             # its highest CCN
 70FILE_ROLE = Qt.UserRole + 2  # a row's file (its FileMetrics' path: a procedure in an included file's is the includer's)
 71
 72
 73class ComplexityPanel(QWidget):
 74    """The files (top), their procedures (below), their totals, and the
 75    selected procedure's control-flow graph (beside them)."""
 76
 77    open_requested = Signal(str, int)  # file, line
 78    refresh_requested = Signal()
 79
 80    def __init__(self, parent=None):
 81        super().__init__(parent)
 82        self.files: list[cx.FileMetrics] = []
 83        self._labels: dict[Path, str] = {}  # (a file's label, worked out once: see label)
 84        self._is_test: dict[Path, bool] = {}
 85        self.project_dir: Path | None = None
 86        self.test_dirs: list[Path] = []
 87        self.dependency_dirs: list[Path] = []
 88        self.thresholds = dict(cx.THRESHOLDS)
 89        self.description = ""  # (when it was measured)
 90        self.icon_for = None  # a file's QIcon, as in Project files (set by the window)
 91        self.is_light = True
 92        self.palette_colors = PALETTES["light"]
 93        self.setObjectName("ComplexityPanel")
 94        self.setAttribute(Qt.WA_StyledBackground, True)
 95
 96        self.title_icon = TitleIcon("complexity")
 97        self.title = QLabel("Complexity")
 98        self.subtitle = StatusLabel()
 99        self.tests_check = QCheckBox("Include tests")
100        self.tests_check.setToolTip("Also measure the test programs")
101        self.tests_check.toggled.connect(self._fill)
102        self.dependencies_check = QCheckBox("Include dependencies")
103        self.dependencies_check.setToolTip("Also measure the dependencies' code")
104        self.dependencies_check.toggled.connect(self.refresh_requested)
105        header = QHBoxLayout()
106        header.setSpacing(6)
107        header.addWidget(self.title_icon)
108        header.addWidget(self.title)
109        header.addSpacing(4)
110        header.addWidget(self.subtitle, 1)
111        header.addWidget(self.tests_check)
112        header.addSpacing(12)
113        header.addWidget(self.dependencies_check)
114
115        self.filter_edit = QLineEdit()
116        self.filter_edit.setPlaceholderText("Filter by procedure or file")
117        self.filter_edit.setClearButtonEnabled(True)
118        self.filter_edit.setMinimumWidth(160)  # (narrow: the totals beside it are cut off, not the filter)
119        self.filter_edit.textChanged.connect(self._fill_procedures)
120        self.totals = StatusLabel()
121        totals_row = QHBoxLayout()
122        totals_row.setSpacing(10)
123        totals_row.addWidget(self.filter_edit, 1)
124        totals_row.addWidget(self.totals)
125
126        metric = {"CCN": "ccn", "NLOC": "nloc", "TOKENS": "tokens", "PARAMS": "params", "LENGTH": "length",
127                  "DEPTH": "depth"}
128        procedure_columns = [Column("FILE", lambda r: r.label), Column("PROCEDURE", lambda r: r.proc.name)] + [
129            Column(name, lambda r, k=key: f"{getattr(r.proc, k):,}", lambda r, k=key: getattr(r.proc, k),
130                   COLUMN_TIPS.get(name, "")) for name, key in metric.items()]
131        self.procedures = self._table(procedure_columns, RowStyle(
132            color=self._procedure_color, bold=self._procedure_bold, icon=lambda r: symbol_icon(r.proc.kind),
133            tip=lambda r: self._procedure_tip(r.proc, r.label, r.over), data=self._procedure_data,
134            icon_column=NAME_COLUMN))
135        self.procedures.horizontalHeader().setSortIndicator(2, Qt.DescendingOrder)  # (the most complex first)
136        self.procedures.cellClicked.connect(self._procedure_clicked)
137        file_columns = [
138            Column("FILE", lambda r: r.label),
139            Column("NLOC", lambda r: f"{r.file.nloc:,}", lambda r: r.file.nloc, COLUMN_TIPS["NLOC"]),
140            Column("PROCEDURES", lambda r: str(len(r.file.procedures)), lambda r: len(r.file.procedures)),
141            Column("AVG CCN", lambda r: f"{r.file.average_ccn:.1f}", lambda r: r.file.average_ccn),
142            Column("MAX CCN", lambda r: str(r.highest), lambda r: r.highest),
143            Column("OVER", lambda r: str(r.over), lambda r: r.over, COLUMN_TIPS["OVER"])]
144        self.file_table = self._table(file_columns, RowStyle(
145            color=lambda r, column: QColor(OVER) if column == len(FILE_COLUMNS) - 1 and r.over else None,
146            icon=lambda r: self.icon_for(r.file.path) if self.icon_for is not None else None,
147            tip=lambda r: f"{html.escape(str(r.file.path))}<br>Click to list only its procedures",
148            data=lambda r, role: r.label if role == Qt.UserRole else r.file.path if role == FILE_ROLE else None))
149        self.file_table.horizontalHeader().setSortIndicator(3, Qt.DescendingOrder)
150        # A file chosen (clicked, or with the arrow keys): only its procedures;
151        # the chosen file clicked again: all of them.
152        self.file_table.currentCellChanged.connect(self._file_chosen)
153        self.file_table.cellClicked.connect(self._file_clicked)
154        self._file_just_chosen = False
155        self.splitter = QSplitter(Qt.Vertical)  # (the files above their procedures)
156        self.splitter.addWidget(self.file_table)
157        self.splitter.addWidget(self.procedures)
158        self.splitter.setStretchFactor(0, 1)
159        self.splitter.setStretchFactor(1, 3)
160        # The selected procedure's control flow, beside the tables.
161        self.flow_title = StatusLabel()
162        self.flow = CfgView()
163        self.flow.line_clicked.connect(self._flow_line_clicked)
164        self.flow_shown: cx.Procedure | None = None
165        flow_box = QWidget()
166        flow_layout = QVBoxLayout(flow_box)
167        flow_layout.setContentsMargins(0, 0, 0, 0)
168        flow_layout.setSpacing(4)
169        flow_layout.addWidget(self.flow_title)
170        flow_layout.addWidget(self.flow, 1)
171        self.body = QSplitter(Qt.Horizontal)
172        self.body.addWidget(self.splitter)
173        self.body.addWidget(flow_box)
174        self.body.setStretchFactor(0, 3)
175        self.body.setStretchFactor(1, 2)
176        self.procedures.currentCellChanged.connect(lambda row, *_: self._show_flow(row))
177
178        self.empty = QLabel()
179        self.empty.setAlignment(Qt.AlignCenter)
180        self.empty.setWordWrap(True)
181
182        layout = QVBoxLayout(self)
183        layout.setContentsMargins(14, 10, 14, 6)
184        layout.setSpacing(8)
185        layout.addLayout(header)
186        layout.addLayout(totals_row)
187        layout.addWidget(self.body, 1)
188        layout.addWidget(self.empty, 1)
189        self.refresh_fonts()
190        self.show_message("Open a project to see the complexity of its Fortran code here.")
191
192    def _table(self, columns: list[Column], style: RowStyle) -> MetricsTable:
193        """A table whose columns are as wide as their contents, scrolling
194        sideways if the panel is narrower (not squeezing the first column);
195        its cells drawn on demand (see `vibefpmgui.metrics_table`)."""
196        table = MetricsTable(columns, style)
197        table.verticalHeader().setVisible(False)
198        table.setShowGrid(False)
199        table.setEditTriggers(QAbstractItemView.NoEditTriggers)
200        table.setSelectionBehavior(QAbstractItemView.SelectRows)
201        table.setSelectionMode(QAbstractItemView.SingleSelection)
202        table.setSortingEnabled(True)
203        table.setWordWrap(False)
204        table.setTextElideMode(Qt.ElideLeft)  # (a long path: the end of its file name still shows)
205        table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
206        hh = table.horizontalHeader()
207        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
208        hh.setSectionResizeMode(QHeaderView.Interactive)
209        hh.setStretchLastSection(False)
210        hh.sortIndicatorChanged.connect(lambda *_: self._fit_columns(table))
211        return table
212
213    @staticmethod
214    def _fit_columns(table: MetricsTable):
215        """Each column as wide as its values and its title, room for the sort
216        arrow only in the sorted one (Qt leaves room for it in every title)."""
217        header = table.horizontalHeader()
218        header.setSortIndicatorShown(False)
219        table.resizeColumnsToContents()
220        header.setSortIndicatorShown(True)
221        column = header.sortIndicatorSection()
222        if 0 <= column < header.count():
223            header.resizeSection(column, max(header.sectionSize(column), header.sectionSizeHint(column)))
224        for column in range(header.count()):
225            if header.sectionSize(column) > MAX_TEXT_WIDTH:
226                header.resizeSection(column, MAX_TEXT_WIDTH)
227
228    # ------------------------------------------------------------ styling
229    def refresh_palette(self):
230        self.is_light = app_is_light()
231        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
232        self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
233                           + f"#{self.objectName()} QHeaderView::section {{ padding: 6px 8px; }}")
234        self.title_icon.draw(pal["text"], self.title)
235        for label in (self.subtitle, self.empty, self.totals, self.flow_title):
236            label.setStyleSheet(f"color: {pal['muted']};")
237        self.flow.set_dark(not self.is_light)
238        self._fill()
239
240    def refresh_fonts(self):
241        font = QFont(QApplication.font())
242        font.setPointSizeF(font.pointSizeF() + 2)
243        font.setWeight(QFont.Bold)
244        self.title.setFont(font)
245        self.refresh_palette()
246
247    # -------------------------------------------------------------- state
248    def show_message(self, message: str):
249        """Nothing to list: why, or what's happening."""
250        self.empty.setText(message)
251        self.empty.show()
252        self.body.hide()
253        self.filter_edit.setEnabled(False)
254        self.totals.setText("")
255
256    def show_metrics(self, files: list[cx.FileMetrics], project_dir: Path, test_dirs: list[Path] | None = None,
257                     dependency_dirs: list[Path] | None = None, description: str = ""):
258        """Every measured file's metrics (the project's tests and the
259        dependencies' are listed only with their boxes ticked)."""
260        place = (project_dir.resolve(), [Path(d).resolve() for d in test_dirs or []],
261                 [Path(d).resolve() for d in dependency_dirs or []])
262        if place != (self.project_dir, self.test_dirs, self.dependency_dirs):
263            self._labels.clear()
264            self._is_test.clear()
265        self.files = files
266        self.project_dir, self.test_dirs, self.dependency_dirs = place
267        self.description = description
268        self._fill()
269
270    def shown_files(self) -> list[cx.FileMetrics]:
271        """The files listed: the project's own, with its tests if ticked
272        (the dependencies' are only measured when their box is ticked)."""
273        if self.tests_check.isChecked():
274            return list(self.files)
275        return [f for f in self.files if not self._test_file(f.path)]
276
277    def _test_file(self, path: Path) -> bool:
278        found = self._is_test.get(path)
279        if found is None:
280            resolved = path.resolve()
281            found = self._is_test[path] = any(resolved.is_relative_to(d) for d in self.test_dirs)
282        return found
283
284    def label(self, path: Path) -> str:
285        """A file's name as listed: its path in the project (or in its dependency)."""
286        label = self._labels.get(path)
287        if label is None:
288            label = self._labels[path] = self._label(path)
289        return label
290
291    def _label(self, path: Path) -> str:
292        path = path.resolve()
293        for top in (*self.dependency_dirs, self.project_dir):
294            if top is not None and path.is_relative_to(top):
295                relative = path.relative_to(top).as_posix()
296                return relative if top == self.project_dir else f"{top.name}/{relative}"
297        return str(path)
298
299    def _fill(self):
300        files = self.shown_files()
301        if not self.files:
302            if self.project_dir is not None:
303                self.show_message("No Fortran files in this project.")
304            return
305        self.empty.hide()
306        self.body.show()
307        self.filter_edit.setEnabled(True)
308        self._update_totals(files)
309        self._fill_files(files)
310        self._fill_procedures()
311
312    def _update_totals(self, files: list[cx.FileMetrics]):
313        self.subtitle.setText(" · ".join(part for part in (self.description, f"{len(files)} files") if part))
314        procedures = [p for f in files for p in f.procedures]
315        over = [p for p in procedures if p.warnings(self.thresholds)]
316        nloc = sum(f.nloc for f in files)
317        average_ccn = sum(p.ccn for p in procedures) / len(procedures) if procedures else 0.0
318        average_nloc = sum(p.nloc for p in procedures) / len(procedures) if procedures else 0.0
319        self.totals.setText(f"{nloc:,} lines of code · {len(procedures):,} procedures · average CCN "
320                            f"{average_ccn:.1f}, NLOC {average_nloc:.0f} · "
321                            + (f"<span style='color:{OVER}'>{len(over)} over a threshold</span>" if over
322                               else "none over a threshold"))
323        self.totals.setToolTip("Thresholds (lizard's): " + ", ".join(
324            f"{key.upper() if key == 'ccn' else key} {value}" for key, value in self.thresholds.items()))
325
326    def chosen_file(self) -> str | None:
327        """The file chosen in the files' table (its label), if the filter is
328        still that file's."""
329        row = self.file_table.currentRow()
330        item = self.file_table.item(row, 0) if row >= 0 else None
331        label = item.data(Qt.UserRole) if item is not None else None
332        return label if label and label == self.filter_edit.text() else None
333
334    def _listed(self, files: list[cx.FileMetrics]) -> list[tuple[cx.FileMetrics, cx.Procedure, str]]:
335        """(file, procedure, file's label) of the files' procedures the filter
336        lets through (a file chosen: only that file's)."""
337        needle = self.filter_edit.text().strip().lower()
338        chosen = self.chosen_file()
339        rows = []
340        for f in files:
341            label = self.label(f.path)
342            if chosen is not None and label != chosen:  # (only that file's: not another path it's a part of)
343                continue
344            for proc in f.procedures:
345                if chosen is None and needle and needle not in proc.qualified.lower() and needle not in label.lower():
346                    continue
347                rows.append((f, proc, label))
348        return rows
349
350    def _fill_procedures(self):
351        rows = [ProcedureRow(f, proc, label, frozenset(proc.warnings(self.thresholds)))
352                for f, proc, label in self._listed(self.shown_files())]
353        self.procedures.setColumnHidden(FILE_COLUMN, self.chosen_file() is not None)  # (one file: no need to say which)
354        table = self.procedures
355        table.blockSignals(True)  # (refilling isn't choosing a procedure: see the end)
356        table.set_rows(rows)
357        self._fit_columns(table)
358        table.blockSignals(False)
359        self._keep_flow()
360
361    def _procedure_color(self, row: ProcedureRow, column: int) -> QColor | None:
362        if column == FILE_COLUMN:
363            return QColor(self.palette_colors["muted"])
364        return QColor(OVER) if METRIC_OF_COLUMN.get(PROCEDURE_COLUMNS[column]) in row.over else None
365
366    def _procedure_bold(self, row: ProcedureRow, column: int) -> bool:
367        return METRIC_OF_COLUMN.get(PROCEDURE_COLUMNS[column]) in row.over
368
369    @staticmethod
370    def _procedure_data(row: ProcedureRow, role: int):
371        if role == Qt.UserRole:
372            return str(row.proc.path), row.proc.start
373        if role == PROCEDURE_ROLE:
374            return row.proc
375        if role == FILE_ROLE:
376            return row.file.path
377        return None
378
379    # ------------------------------------------------------------ control flow
380    def set_code_font(self, font: QFont):
381        """The graph's font (the editor's)."""
382        self.flow.set_code_font(font)
383
384    def _row_of(self, path: Path, qualified: str) -> int | None:
385        return next((n for n, row in enumerate(self.procedures.rows())
386                     if row.proc.path == path and row.proc.qualified == qualified), None)
387
388    def _keep_flow(self):
389        """After the tables are filled again (a save, a filter, a file chosen):
390        the procedure shown stays selected, its graph drawn again from its file
391        (it may have changed); filtered out, or a file just chosen, the first
392        procedure listed is selected and shown instead; gone from the project,
393        the graph goes too."""
394        shown = self.flow_shown
395        row = self._row_of(shown.path, shown.qualified) if shown is not None else None
396        if row is None and self.procedures.rowCount() and (shown is not None or self.chosen_file()):
397            row = 0  # (filtered out, or a file chosen: its most complex procedure)
398        if row is None and shown is None:
399            return
400        if row is None:
401            if not any(p.path == shown.path and p.qualified == shown.qualified
402                       for f in self.files for p in f.procedures):
403                self.flow_shown = None
404                self.flow_title.setText("")
405                self.flow.show_message("Select a procedure to see its control flow.")
406            return
407        self.procedures.blockSignals(True)
408        self.procedures.setCurrentCell(row, NAME_COLUMN)
409        self.procedures.blockSignals(False)
410        self._show_flow(row, again=True)
411
412    def _show_flow(self, row: int, again: bool = False):
413        """The control-flow graph of the procedure in ``row``."""
414        item = self.procedures.item(row, NAME_COLUMN) if row >= 0 else None
415        proc = item.data(PROCEDURE_ROLE) if item is not None else None
416        if proc is None or (not again and proc is self.flow_shown):
417            return
418        self.flow_shown = proc
419        try:
420            text = read_source(proc.path).text
421        except OSError as error:
422            self.flow.show_message(f"Couldn't read {proc.path.name}: {error}")
423            return
424        graph = cf.build(text, proc.path, proc)
425        decisions = graph.complexity - 1
426        self.flow_title.setText(f"<b>{html.escape(proc.name)}</b> · lines {proc.start}–{proc.end} · "
427                                f"{decisions} decision{'s' * (decisions != 1)} · click a node to open it, "
428                                "right-click for more")
429        self.flow.show_graph(graph, text)
430
431    def _flow_line_clicked(self, path: str, line: int):
432        if self.flow_shown is not None:
433            self.open_requested.emit(path or str(self.flow_shown.path), line)
434
435    def _procedure_tip(self, proc: cx.Procedure, label: str, over: set[str]) -> str:
436        lines = [f"<b>{html.escape(proc.qualified)}</b> ({proc.kind})",
437                 f"{html.escape(label)}, lines {proc.start}–{proc.end}"]
438        if over:
439            names = {"ccn": "CCN", "length": "length", "params": "parameters", "depth": "depth"}
440            over_text = ", ".join(f"{names[key]} (over {self.thresholds[key]})" for key in sorted(over))
441            lines.append(f"<span style='color:{OVER}'>Over: {over_text}</span>")
442        return "<br>".join(lines)
443
444    def _fill_files(self, files: list[cx.FileMetrics]):
445        table = self.file_table
446        chosen = self.chosen_file()
447        table.blockSignals(True)  # (refilling isn't choosing a file)
448        table.set_rows([FileRow(f, self.label(f.path), sum(1 for p in f.procedures if p.warnings(self.thresholds)),
449                                max((p.ccn for p in f.procedures), default=0)) for f in files])
450        self._fit_columns(table)
451        table.setCurrentCell(-1, -1)
452        row = next((n for n, r in enumerate(table.rows()) if r.label == chosen), None)
453        if row is not None:  # (still chosen: a save refills the tables)
454            table.setCurrentCell(row, 0)
455        table.blockSignals(False)
456
457    def _procedure_clicked(self, row: int, _column: int):
458        item = self.procedures.item(row, NAME_COLUMN)
459        if item is not None:
460            path, line = item.data(Qt.UserRole)
461            self.open_requested.emit(path, line)
462
463    def _file_chosen(self, row: int, _column: int, previous_row: int, _previous_column: int):
464        """The current file changed (a click, or an arrow key): only its procedures."""
465        item = self.file_table.item(row, 0) if row >= 0 else None
466        if item is None or row == previous_row:
467            return
468        # (The click that chose it doesn't also unchoose it; an arrow key isn't a click.)
469        self._file_just_chosen = bool(QApplication.mouseButtons() & Qt.LeftButton)
470        self.filter_edit.setText(item.data(Qt.UserRole))
471
472    def _file_clicked(self, row: int, _column: int):
473        """A click on the file already chosen: all the procedures again."""
474        if self._file_just_chosen:
475            self._file_just_chosen = False
476            return
477        item = self.file_table.item(row, 0)
478        if item is not None and self.filter_edit.text() == item.data(Qt.UserRole):
479            self.filter_edit.clear()
480            self.file_table.blockSignals(True)
481            self.file_table.setCurrentCell(-1, -1)
482            self.file_table.blockSignals(False)
OVER = '#e03131'
PROCEDURE_COLUMNS = ('FILE', 'PROCEDURE', 'CCN', 'NLOC', 'TOKENS', 'PARAMS', 'LENGTH', 'DEPTH')
MAX_TEXT_WIDTH = 320
FILE_COLUMNS = ('FILE', 'NLOC', 'PROCEDURES', 'AVG CCN', 'MAX CCN', 'OVER')
COLUMN_TIPS = {'CCN': 'Cyclomatic complexity: 1 + its decisions (if, else if, loops, case, type is, where, .and., .or.)', 'NLOC': 'Lines of code (not blank, not only a comment)', 'TOKENS': 'Tokens in its code', 'PARAMS': 'Dummy arguments', 'LENGTH': 'Lines, first to last (with comments and its internal procedures)', 'DEPTH': 'How deeply its block constructs (do, if…then, select…) nest', 'OVER': 'Procedures with a value over its threshold'}
METRIC_OF_COLUMN = {'CCN': 'ccn', 'PARAMS': 'params', 'LENGTH': 'length', 'DEPTH': 'depth'}
PROCEDURE_ROLE = 257
class ProcedureRow(typing.NamedTuple):
59class ProcedureRow(NamedTuple):
60    file: cx.FileMetrics
61    proc: cx.Procedure
62    label: str               # its file's label
63    over: frozenset          # its metrics over their thresholds ("ccn"…)

ProcedureRow(file, proc, label, over)

ProcedureRow( file: cx.FileMetrics, proc: cx.Procedure, label: str, over: frozenset)

Create new instance of ProcedureRow(file, proc, label, over)

Alias for field number 0

Alias for field number 1

label: str

Alias for field number 2

over: frozenset

Alias for field number 3

class FileRow(typing.NamedTuple):
66class FileRow(NamedTuple):
67    file: cx.FileMetrics
68    label: str
69    over: int                # its procedures over a threshold
70    highest: int             # its highest CCN

FileRow(file, label, over, highest)

FileRow(file: cx.FileMetrics, label: str, over: int, highest: int)

Create new instance of FileRow(file, label, over, highest)

Alias for field number 0

label: str

Alias for field number 1

over: int

Alias for field number 2

highest: int

Alias for field number 3

FILE_ROLE = 258
class ComplexityPanel(PySide6.QtWidgets.QWidget):
 74class ComplexityPanel(QWidget):
 75    """The files (top), their procedures (below), their totals, and the
 76    selected procedure's control-flow graph (beside them)."""
 77
 78    open_requested = Signal(str, int)  # file, line
 79    refresh_requested = Signal()
 80
 81    def __init__(self, parent=None):
 82        super().__init__(parent)
 83        self.files: list[cx.FileMetrics] = []
 84        self._labels: dict[Path, str] = {}  # (a file's label, worked out once: see label)
 85        self._is_test: dict[Path, bool] = {}
 86        self.project_dir: Path | None = None
 87        self.test_dirs: list[Path] = []
 88        self.dependency_dirs: list[Path] = []
 89        self.thresholds = dict(cx.THRESHOLDS)
 90        self.description = ""  # (when it was measured)
 91        self.icon_for = None  # a file's QIcon, as in Project files (set by the window)
 92        self.is_light = True
 93        self.palette_colors = PALETTES["light"]
 94        self.setObjectName("ComplexityPanel")
 95        self.setAttribute(Qt.WA_StyledBackground, True)
 96
 97        self.title_icon = TitleIcon("complexity")
 98        self.title = QLabel("Complexity")
 99        self.subtitle = StatusLabel()
100        self.tests_check = QCheckBox("Include tests")
101        self.tests_check.setToolTip("Also measure the test programs")
102        self.tests_check.toggled.connect(self._fill)
103        self.dependencies_check = QCheckBox("Include dependencies")
104        self.dependencies_check.setToolTip("Also measure the dependencies' code")
105        self.dependencies_check.toggled.connect(self.refresh_requested)
106        header = QHBoxLayout()
107        header.setSpacing(6)
108        header.addWidget(self.title_icon)
109        header.addWidget(self.title)
110        header.addSpacing(4)
111        header.addWidget(self.subtitle, 1)
112        header.addWidget(self.tests_check)
113        header.addSpacing(12)
114        header.addWidget(self.dependencies_check)
115
116        self.filter_edit = QLineEdit()
117        self.filter_edit.setPlaceholderText("Filter by procedure or file")
118        self.filter_edit.setClearButtonEnabled(True)
119        self.filter_edit.setMinimumWidth(160)  # (narrow: the totals beside it are cut off, not the filter)
120        self.filter_edit.textChanged.connect(self._fill_procedures)
121        self.totals = StatusLabel()
122        totals_row = QHBoxLayout()
123        totals_row.setSpacing(10)
124        totals_row.addWidget(self.filter_edit, 1)
125        totals_row.addWidget(self.totals)
126
127        metric = {"CCN": "ccn", "NLOC": "nloc", "TOKENS": "tokens", "PARAMS": "params", "LENGTH": "length",
128                  "DEPTH": "depth"}
129        procedure_columns = [Column("FILE", lambda r: r.label), Column("PROCEDURE", lambda r: r.proc.name)] + [
130            Column(name, lambda r, k=key: f"{getattr(r.proc, k):,}", lambda r, k=key: getattr(r.proc, k),
131                   COLUMN_TIPS.get(name, "")) for name, key in metric.items()]
132        self.procedures = self._table(procedure_columns, RowStyle(
133            color=self._procedure_color, bold=self._procedure_bold, icon=lambda r: symbol_icon(r.proc.kind),
134            tip=lambda r: self._procedure_tip(r.proc, r.label, r.over), data=self._procedure_data,
135            icon_column=NAME_COLUMN))
136        self.procedures.horizontalHeader().setSortIndicator(2, Qt.DescendingOrder)  # (the most complex first)
137        self.procedures.cellClicked.connect(self._procedure_clicked)
138        file_columns = [
139            Column("FILE", lambda r: r.label),
140            Column("NLOC", lambda r: f"{r.file.nloc:,}", lambda r: r.file.nloc, COLUMN_TIPS["NLOC"]),
141            Column("PROCEDURES", lambda r: str(len(r.file.procedures)), lambda r: len(r.file.procedures)),
142            Column("AVG CCN", lambda r: f"{r.file.average_ccn:.1f}", lambda r: r.file.average_ccn),
143            Column("MAX CCN", lambda r: str(r.highest), lambda r: r.highest),
144            Column("OVER", lambda r: str(r.over), lambda r: r.over, COLUMN_TIPS["OVER"])]
145        self.file_table = self._table(file_columns, RowStyle(
146            color=lambda r, column: QColor(OVER) if column == len(FILE_COLUMNS) - 1 and r.over else None,
147            icon=lambda r: self.icon_for(r.file.path) if self.icon_for is not None else None,
148            tip=lambda r: f"{html.escape(str(r.file.path))}<br>Click to list only its procedures",
149            data=lambda r, role: r.label if role == Qt.UserRole else r.file.path if role == FILE_ROLE else None))
150        self.file_table.horizontalHeader().setSortIndicator(3, Qt.DescendingOrder)
151        # A file chosen (clicked, or with the arrow keys): only its procedures;
152        # the chosen file clicked again: all of them.
153        self.file_table.currentCellChanged.connect(self._file_chosen)
154        self.file_table.cellClicked.connect(self._file_clicked)
155        self._file_just_chosen = False
156        self.splitter = QSplitter(Qt.Vertical)  # (the files above their procedures)
157        self.splitter.addWidget(self.file_table)
158        self.splitter.addWidget(self.procedures)
159        self.splitter.setStretchFactor(0, 1)
160        self.splitter.setStretchFactor(1, 3)
161        # The selected procedure's control flow, beside the tables.
162        self.flow_title = StatusLabel()
163        self.flow = CfgView()
164        self.flow.line_clicked.connect(self._flow_line_clicked)
165        self.flow_shown: cx.Procedure | None = None
166        flow_box = QWidget()
167        flow_layout = QVBoxLayout(flow_box)
168        flow_layout.setContentsMargins(0, 0, 0, 0)
169        flow_layout.setSpacing(4)
170        flow_layout.addWidget(self.flow_title)
171        flow_layout.addWidget(self.flow, 1)
172        self.body = QSplitter(Qt.Horizontal)
173        self.body.addWidget(self.splitter)
174        self.body.addWidget(flow_box)
175        self.body.setStretchFactor(0, 3)
176        self.body.setStretchFactor(1, 2)
177        self.procedures.currentCellChanged.connect(lambda row, *_: self._show_flow(row))
178
179        self.empty = QLabel()
180        self.empty.setAlignment(Qt.AlignCenter)
181        self.empty.setWordWrap(True)
182
183        layout = QVBoxLayout(self)
184        layout.setContentsMargins(14, 10, 14, 6)
185        layout.setSpacing(8)
186        layout.addLayout(header)
187        layout.addLayout(totals_row)
188        layout.addWidget(self.body, 1)
189        layout.addWidget(self.empty, 1)
190        self.refresh_fonts()
191        self.show_message("Open a project to see the complexity of its Fortran code here.")
192
193    def _table(self, columns: list[Column], style: RowStyle) -> MetricsTable:
194        """A table whose columns are as wide as their contents, scrolling
195        sideways if the panel is narrower (not squeezing the first column);
196        its cells drawn on demand (see `vibefpmgui.metrics_table`)."""
197        table = MetricsTable(columns, style)
198        table.verticalHeader().setVisible(False)
199        table.setShowGrid(False)
200        table.setEditTriggers(QAbstractItemView.NoEditTriggers)
201        table.setSelectionBehavior(QAbstractItemView.SelectRows)
202        table.setSelectionMode(QAbstractItemView.SingleSelection)
203        table.setSortingEnabled(True)
204        table.setWordWrap(False)
205        table.setTextElideMode(Qt.ElideLeft)  # (a long path: the end of its file name still shows)
206        table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
207        hh = table.horizontalHeader()
208        hh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter)
209        hh.setSectionResizeMode(QHeaderView.Interactive)
210        hh.setStretchLastSection(False)
211        hh.sortIndicatorChanged.connect(lambda *_: self._fit_columns(table))
212        return table
213
214    @staticmethod
215    def _fit_columns(table: MetricsTable):
216        """Each column as wide as its values and its title, room for the sort
217        arrow only in the sorted one (Qt leaves room for it in every title)."""
218        header = table.horizontalHeader()
219        header.setSortIndicatorShown(False)
220        table.resizeColumnsToContents()
221        header.setSortIndicatorShown(True)
222        column = header.sortIndicatorSection()
223        if 0 <= column < header.count():
224            header.resizeSection(column, max(header.sectionSize(column), header.sectionSizeHint(column)))
225        for column in range(header.count()):
226            if header.sectionSize(column) > MAX_TEXT_WIDTH:
227                header.resizeSection(column, MAX_TEXT_WIDTH)
228
229    # ------------------------------------------------------------ styling
230    def refresh_palette(self):
231        self.is_light = app_is_light()
232        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
233        self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
234                           + f"#{self.objectName()} QHeaderView::section {{ padding: 6px 8px; }}")
235        self.title_icon.draw(pal["text"], self.title)
236        for label in (self.subtitle, self.empty, self.totals, self.flow_title):
237            label.setStyleSheet(f"color: {pal['muted']};")
238        self.flow.set_dark(not self.is_light)
239        self._fill()
240
241    def refresh_fonts(self):
242        font = QFont(QApplication.font())
243        font.setPointSizeF(font.pointSizeF() + 2)
244        font.setWeight(QFont.Bold)
245        self.title.setFont(font)
246        self.refresh_palette()
247
248    # -------------------------------------------------------------- state
249    def show_message(self, message: str):
250        """Nothing to list: why, or what's happening."""
251        self.empty.setText(message)
252        self.empty.show()
253        self.body.hide()
254        self.filter_edit.setEnabled(False)
255        self.totals.setText("")
256
257    def show_metrics(self, files: list[cx.FileMetrics], project_dir: Path, test_dirs: list[Path] | None = None,
258                     dependency_dirs: list[Path] | None = None, description: str = ""):
259        """Every measured file's metrics (the project's tests and the
260        dependencies' are listed only with their boxes ticked)."""
261        place = (project_dir.resolve(), [Path(d).resolve() for d in test_dirs or []],
262                 [Path(d).resolve() for d in dependency_dirs or []])
263        if place != (self.project_dir, self.test_dirs, self.dependency_dirs):
264            self._labels.clear()
265            self._is_test.clear()
266        self.files = files
267        self.project_dir, self.test_dirs, self.dependency_dirs = place
268        self.description = description
269        self._fill()
270
271    def shown_files(self) -> list[cx.FileMetrics]:
272        """The files listed: the project's own, with its tests if ticked
273        (the dependencies' are only measured when their box is ticked)."""
274        if self.tests_check.isChecked():
275            return list(self.files)
276        return [f for f in self.files if not self._test_file(f.path)]
277
278    def _test_file(self, path: Path) -> bool:
279        found = self._is_test.get(path)
280        if found is None:
281            resolved = path.resolve()
282            found = self._is_test[path] = any(resolved.is_relative_to(d) for d in self.test_dirs)
283        return found
284
285    def label(self, path: Path) -> str:
286        """A file's name as listed: its path in the project (or in its dependency)."""
287        label = self._labels.get(path)
288        if label is None:
289            label = self._labels[path] = self._label(path)
290        return label
291
292    def _label(self, path: Path) -> str:
293        path = path.resolve()
294        for top in (*self.dependency_dirs, self.project_dir):
295            if top is not None and path.is_relative_to(top):
296                relative = path.relative_to(top).as_posix()
297                return relative if top == self.project_dir else f"{top.name}/{relative}"
298        return str(path)
299
300    def _fill(self):
301        files = self.shown_files()
302        if not self.files:
303            if self.project_dir is not None:
304                self.show_message("No Fortran files in this project.")
305            return
306        self.empty.hide()
307        self.body.show()
308        self.filter_edit.setEnabled(True)
309        self._update_totals(files)
310        self._fill_files(files)
311        self._fill_procedures()
312
313    def _update_totals(self, files: list[cx.FileMetrics]):
314        self.subtitle.setText(" · ".join(part for part in (self.description, f"{len(files)} files") if part))
315        procedures = [p for f in files for p in f.procedures]
316        over = [p for p in procedures if p.warnings(self.thresholds)]
317        nloc = sum(f.nloc for f in files)
318        average_ccn = sum(p.ccn for p in procedures) / len(procedures) if procedures else 0.0
319        average_nloc = sum(p.nloc for p in procedures) / len(procedures) if procedures else 0.0
320        self.totals.setText(f"{nloc:,} lines of code · {len(procedures):,} procedures · average CCN "
321                            f"{average_ccn:.1f}, NLOC {average_nloc:.0f} · "
322                            + (f"<span style='color:{OVER}'>{len(over)} over a threshold</span>" if over
323                               else "none over a threshold"))
324        self.totals.setToolTip("Thresholds (lizard's): " + ", ".join(
325            f"{key.upper() if key == 'ccn' else key} {value}" for key, value in self.thresholds.items()))
326
327    def chosen_file(self) -> str | None:
328        """The file chosen in the files' table (its label), if the filter is
329        still that file's."""
330        row = self.file_table.currentRow()
331        item = self.file_table.item(row, 0) if row >= 0 else None
332        label = item.data(Qt.UserRole) if item is not None else None
333        return label if label and label == self.filter_edit.text() else None
334
335    def _listed(self, files: list[cx.FileMetrics]) -> list[tuple[cx.FileMetrics, cx.Procedure, str]]:
336        """(file, procedure, file's label) of the files' procedures the filter
337        lets through (a file chosen: only that file's)."""
338        needle = self.filter_edit.text().strip().lower()
339        chosen = self.chosen_file()
340        rows = []
341        for f in files:
342            label = self.label(f.path)
343            if chosen is not None and label != chosen:  # (only that file's: not another path it's a part of)
344                continue
345            for proc in f.procedures:
346                if chosen is None and needle and needle not in proc.qualified.lower() and needle not in label.lower():
347                    continue
348                rows.append((f, proc, label))
349        return rows
350
351    def _fill_procedures(self):
352        rows = [ProcedureRow(f, proc, label, frozenset(proc.warnings(self.thresholds)))
353                for f, proc, label in self._listed(self.shown_files())]
354        self.procedures.setColumnHidden(FILE_COLUMN, self.chosen_file() is not None)  # (one file: no need to say which)
355        table = self.procedures
356        table.blockSignals(True)  # (refilling isn't choosing a procedure: see the end)
357        table.set_rows(rows)
358        self._fit_columns(table)
359        table.blockSignals(False)
360        self._keep_flow()
361
362    def _procedure_color(self, row: ProcedureRow, column: int) -> QColor | None:
363        if column == FILE_COLUMN:
364            return QColor(self.palette_colors["muted"])
365        return QColor(OVER) if METRIC_OF_COLUMN.get(PROCEDURE_COLUMNS[column]) in row.over else None
366
367    def _procedure_bold(self, row: ProcedureRow, column: int) -> bool:
368        return METRIC_OF_COLUMN.get(PROCEDURE_COLUMNS[column]) in row.over
369
370    @staticmethod
371    def _procedure_data(row: ProcedureRow, role: int):
372        if role == Qt.UserRole:
373            return str(row.proc.path), row.proc.start
374        if role == PROCEDURE_ROLE:
375            return row.proc
376        if role == FILE_ROLE:
377            return row.file.path
378        return None
379
380    # ------------------------------------------------------------ control flow
381    def set_code_font(self, font: QFont):
382        """The graph's font (the editor's)."""
383        self.flow.set_code_font(font)
384
385    def _row_of(self, path: Path, qualified: str) -> int | None:
386        return next((n for n, row in enumerate(self.procedures.rows())
387                     if row.proc.path == path and row.proc.qualified == qualified), None)
388
389    def _keep_flow(self):
390        """After the tables are filled again (a save, a filter, a file chosen):
391        the procedure shown stays selected, its graph drawn again from its file
392        (it may have changed); filtered out, or a file just chosen, the first
393        procedure listed is selected and shown instead; gone from the project,
394        the graph goes too."""
395        shown = self.flow_shown
396        row = self._row_of(shown.path, shown.qualified) if shown is not None else None
397        if row is None and self.procedures.rowCount() and (shown is not None or self.chosen_file()):
398            row = 0  # (filtered out, or a file chosen: its most complex procedure)
399        if row is None and shown is None:
400            return
401        if row is None:
402            if not any(p.path == shown.path and p.qualified == shown.qualified
403                       for f in self.files for p in f.procedures):
404                self.flow_shown = None
405                self.flow_title.setText("")
406                self.flow.show_message("Select a procedure to see its control flow.")
407            return
408        self.procedures.blockSignals(True)
409        self.procedures.setCurrentCell(row, NAME_COLUMN)
410        self.procedures.blockSignals(False)
411        self._show_flow(row, again=True)
412
413    def _show_flow(self, row: int, again: bool = False):
414        """The control-flow graph of the procedure in ``row``."""
415        item = self.procedures.item(row, NAME_COLUMN) if row >= 0 else None
416        proc = item.data(PROCEDURE_ROLE) if item is not None else None
417        if proc is None or (not again and proc is self.flow_shown):
418            return
419        self.flow_shown = proc
420        try:
421            text = read_source(proc.path).text
422        except OSError as error:
423            self.flow.show_message(f"Couldn't read {proc.path.name}: {error}")
424            return
425        graph = cf.build(text, proc.path, proc)
426        decisions = graph.complexity - 1
427        self.flow_title.setText(f"<b>{html.escape(proc.name)}</b> · lines {proc.start}–{proc.end} · "
428                                f"{decisions} decision{'s' * (decisions != 1)} · click a node to open it, "
429                                "right-click for more")
430        self.flow.show_graph(graph, text)
431
432    def _flow_line_clicked(self, path: str, line: int):
433        if self.flow_shown is not None:
434            self.open_requested.emit(path or str(self.flow_shown.path), line)
435
436    def _procedure_tip(self, proc: cx.Procedure, label: str, over: set[str]) -> str:
437        lines = [f"<b>{html.escape(proc.qualified)}</b> ({proc.kind})",
438                 f"{html.escape(label)}, lines {proc.start}–{proc.end}"]
439        if over:
440            names = {"ccn": "CCN", "length": "length", "params": "parameters", "depth": "depth"}
441            over_text = ", ".join(f"{names[key]} (over {self.thresholds[key]})" for key in sorted(over))
442            lines.append(f"<span style='color:{OVER}'>Over: {over_text}</span>")
443        return "<br>".join(lines)
444
445    def _fill_files(self, files: list[cx.FileMetrics]):
446        table = self.file_table
447        chosen = self.chosen_file()
448        table.blockSignals(True)  # (refilling isn't choosing a file)
449        table.set_rows([FileRow(f, self.label(f.path), sum(1 for p in f.procedures if p.warnings(self.thresholds)),
450                                max((p.ccn for p in f.procedures), default=0)) for f in files])
451        self._fit_columns(table)
452        table.setCurrentCell(-1, -1)
453        row = next((n for n, r in enumerate(table.rows()) if r.label == chosen), None)
454        if row is not None:  # (still chosen: a save refills the tables)
455            table.setCurrentCell(row, 0)
456        table.blockSignals(False)
457
458    def _procedure_clicked(self, row: int, _column: int):
459        item = self.procedures.item(row, NAME_COLUMN)
460        if item is not None:
461            path, line = item.data(Qt.UserRole)
462            self.open_requested.emit(path, line)
463
464    def _file_chosen(self, row: int, _column: int, previous_row: int, _previous_column: int):
465        """The current file changed (a click, or an arrow key): only its procedures."""
466        item = self.file_table.item(row, 0) if row >= 0 else None
467        if item is None or row == previous_row:
468            return
469        # (The click that chose it doesn't also unchoose it; an arrow key isn't a click.)
470        self._file_just_chosen = bool(QApplication.mouseButtons() & Qt.LeftButton)
471        self.filter_edit.setText(item.data(Qt.UserRole))
472
473    def _file_clicked(self, row: int, _column: int):
474        """A click on the file already chosen: all the procedures again."""
475        if self._file_just_chosen:
476            self._file_just_chosen = False
477            return
478        item = self.file_table.item(row, 0)
479        if item is not None and self.filter_edit.text() == item.data(Qt.UserRole):
480            self.filter_edit.clear()
481            self.file_table.blockSignals(True)
482            self.file_table.setCurrentCell(-1, -1)
483            self.file_table.blockSignals(False)

The files (top), their procedures (below), their totals, and the selected procedure's control-flow graph (beside them).

ComplexityPanel(parent=None)
 81    def __init__(self, parent=None):
 82        super().__init__(parent)
 83        self.files: list[cx.FileMetrics] = []
 84        self._labels: dict[Path, str] = {}  # (a file's label, worked out once: see label)
 85        self._is_test: dict[Path, bool] = {}
 86        self.project_dir: Path | None = None
 87        self.test_dirs: list[Path] = []
 88        self.dependency_dirs: list[Path] = []
 89        self.thresholds = dict(cx.THRESHOLDS)
 90        self.description = ""  # (when it was measured)
 91        self.icon_for = None  # a file's QIcon, as in Project files (set by the window)
 92        self.is_light = True
 93        self.palette_colors = PALETTES["light"]
 94        self.setObjectName("ComplexityPanel")
 95        self.setAttribute(Qt.WA_StyledBackground, True)
 96
 97        self.title_icon = TitleIcon("complexity")
 98        self.title = QLabel("Complexity")
 99        self.subtitle = StatusLabel()
100        self.tests_check = QCheckBox("Include tests")
101        self.tests_check.setToolTip("Also measure the test programs")
102        self.tests_check.toggled.connect(self._fill)
103        self.dependencies_check = QCheckBox("Include dependencies")
104        self.dependencies_check.setToolTip("Also measure the dependencies' code")
105        self.dependencies_check.toggled.connect(self.refresh_requested)
106        header = QHBoxLayout()
107        header.setSpacing(6)
108        header.addWidget(self.title_icon)
109        header.addWidget(self.title)
110        header.addSpacing(4)
111        header.addWidget(self.subtitle, 1)
112        header.addWidget(self.tests_check)
113        header.addSpacing(12)
114        header.addWidget(self.dependencies_check)
115
116        self.filter_edit = QLineEdit()
117        self.filter_edit.setPlaceholderText("Filter by procedure or file")
118        self.filter_edit.setClearButtonEnabled(True)
119        self.filter_edit.setMinimumWidth(160)  # (narrow: the totals beside it are cut off, not the filter)
120        self.filter_edit.textChanged.connect(self._fill_procedures)
121        self.totals = StatusLabel()
122        totals_row = QHBoxLayout()
123        totals_row.setSpacing(10)
124        totals_row.addWidget(self.filter_edit, 1)
125        totals_row.addWidget(self.totals)
126
127        metric = {"CCN": "ccn", "NLOC": "nloc", "TOKENS": "tokens", "PARAMS": "params", "LENGTH": "length",
128                  "DEPTH": "depth"}
129        procedure_columns = [Column("FILE", lambda r: r.label), Column("PROCEDURE", lambda r: r.proc.name)] + [
130            Column(name, lambda r, k=key: f"{getattr(r.proc, k):,}", lambda r, k=key: getattr(r.proc, k),
131                   COLUMN_TIPS.get(name, "")) for name, key in metric.items()]
132        self.procedures = self._table(procedure_columns, RowStyle(
133            color=self._procedure_color, bold=self._procedure_bold, icon=lambda r: symbol_icon(r.proc.kind),
134            tip=lambda r: self._procedure_tip(r.proc, r.label, r.over), data=self._procedure_data,
135            icon_column=NAME_COLUMN))
136        self.procedures.horizontalHeader().setSortIndicator(2, Qt.DescendingOrder)  # (the most complex first)
137        self.procedures.cellClicked.connect(self._procedure_clicked)
138        file_columns = [
139            Column("FILE", lambda r: r.label),
140            Column("NLOC", lambda r: f"{r.file.nloc:,}", lambda r: r.file.nloc, COLUMN_TIPS["NLOC"]),
141            Column("PROCEDURES", lambda r: str(len(r.file.procedures)), lambda r: len(r.file.procedures)),
142            Column("AVG CCN", lambda r: f"{r.file.average_ccn:.1f}", lambda r: r.file.average_ccn),
143            Column("MAX CCN", lambda r: str(r.highest), lambda r: r.highest),
144            Column("OVER", lambda r: str(r.over), lambda r: r.over, COLUMN_TIPS["OVER"])]
145        self.file_table = self._table(file_columns, RowStyle(
146            color=lambda r, column: QColor(OVER) if column == len(FILE_COLUMNS) - 1 and r.over else None,
147            icon=lambda r: self.icon_for(r.file.path) if self.icon_for is not None else None,
148            tip=lambda r: f"{html.escape(str(r.file.path))}<br>Click to list only its procedures",
149            data=lambda r, role: r.label if role == Qt.UserRole else r.file.path if role == FILE_ROLE else None))
150        self.file_table.horizontalHeader().setSortIndicator(3, Qt.DescendingOrder)
151        # A file chosen (clicked, or with the arrow keys): only its procedures;
152        # the chosen file clicked again: all of them.
153        self.file_table.currentCellChanged.connect(self._file_chosen)
154        self.file_table.cellClicked.connect(self._file_clicked)
155        self._file_just_chosen = False
156        self.splitter = QSplitter(Qt.Vertical)  # (the files above their procedures)
157        self.splitter.addWidget(self.file_table)
158        self.splitter.addWidget(self.procedures)
159        self.splitter.setStretchFactor(0, 1)
160        self.splitter.setStretchFactor(1, 3)
161        # The selected procedure's control flow, beside the tables.
162        self.flow_title = StatusLabel()
163        self.flow = CfgView()
164        self.flow.line_clicked.connect(self._flow_line_clicked)
165        self.flow_shown: cx.Procedure | None = None
166        flow_box = QWidget()
167        flow_layout = QVBoxLayout(flow_box)
168        flow_layout.setContentsMargins(0, 0, 0, 0)
169        flow_layout.setSpacing(4)
170        flow_layout.addWidget(self.flow_title)
171        flow_layout.addWidget(self.flow, 1)
172        self.body = QSplitter(Qt.Horizontal)
173        self.body.addWidget(self.splitter)
174        self.body.addWidget(flow_box)
175        self.body.setStretchFactor(0, 3)
176        self.body.setStretchFactor(1, 2)
177        self.procedures.currentCellChanged.connect(lambda row, *_: self._show_flow(row))
178
179        self.empty = QLabel()
180        self.empty.setAlignment(Qt.AlignCenter)
181        self.empty.setWordWrap(True)
182
183        layout = QVBoxLayout(self)
184        layout.setContentsMargins(14, 10, 14, 6)
185        layout.setSpacing(8)
186        layout.addLayout(header)
187        layout.addLayout(totals_row)
188        layout.addWidget(self.body, 1)
189        layout.addWidget(self.empty, 1)
190        self.refresh_fonts()
191        self.show_message("Open a project to see the complexity of its Fortran code here.")

__init__(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None

Initialize self. See help(type(self)) for accurate signature.

def open_requested(unknown):

The type of the None singleton.

def refresh_requested(unknown):

The type of the None singleton.

project_dir: pathlib.Path | None
test_dirs: list[pathlib.Path]
dependency_dirs: list[pathlib.Path]
thresholds
description
icon_for
is_light
palette_colors
title_icon
title
subtitle
tests_check
dependencies_check
filter_edit
totals
procedures
file_table
splitter
flow_title
flow
flow_shown: vibefpmgui.complexity.Procedure | None
body
empty
def refresh_palette(self):
230    def refresh_palette(self):
231        self.is_light = app_is_light()
232        self.palette_colors = pal = PALETTES["light" if self.is_light else "dark"]
233        self.setStyleSheet(panel_stylesheet(self.objectName(), pal)
234                           + f"#{self.objectName()} QHeaderView::section {{ padding: 6px 8px; }}")
235        self.title_icon.draw(pal["text"], self.title)
236        for label in (self.subtitle, self.empty, self.totals, self.flow_title):
237            label.setStyleSheet(f"color: {pal['muted']};")
238        self.flow.set_dark(not self.is_light)
239        self._fill()
def refresh_fonts(self):
241    def refresh_fonts(self):
242        font = QFont(QApplication.font())
243        font.setPointSizeF(font.pointSizeF() + 2)
244        font.setWeight(QFont.Bold)
245        self.title.setFont(font)
246        self.refresh_palette()
def show_message(self, message: str):
249    def show_message(self, message: str):
250        """Nothing to list: why, or what's happening."""
251        self.empty.setText(message)
252        self.empty.show()
253        self.body.hide()
254        self.filter_edit.setEnabled(False)
255        self.totals.setText("")

Nothing to list: why, or what's happening.

def show_metrics( self, files: list[vibefpmgui.complexity.FileMetrics], project_dir: pathlib.Path, test_dirs: list[pathlib.Path] | None = None, dependency_dirs: list[pathlib.Path] | None = None, description: str = ''):
257    def show_metrics(self, files: list[cx.FileMetrics], project_dir: Path, test_dirs: list[Path] | None = None,
258                     dependency_dirs: list[Path] | None = None, description: str = ""):
259        """Every measured file's metrics (the project's tests and the
260        dependencies' are listed only with their boxes ticked)."""
261        place = (project_dir.resolve(), [Path(d).resolve() for d in test_dirs or []],
262                 [Path(d).resolve() for d in dependency_dirs or []])
263        if place != (self.project_dir, self.test_dirs, self.dependency_dirs):
264            self._labels.clear()
265            self._is_test.clear()
266        self.files = files
267        self.project_dir, self.test_dirs, self.dependency_dirs = place
268        self.description = description
269        self._fill()

Every measured file's metrics (the project's tests and the dependencies' are listed only with their boxes ticked).

def shown_files(self) -> list[vibefpmgui.complexity.FileMetrics]:
271    def shown_files(self) -> list[cx.FileMetrics]:
272        """The files listed: the project's own, with its tests if ticked
273        (the dependencies' are only measured when their box is ticked)."""
274        if self.tests_check.isChecked():
275            return list(self.files)
276        return [f for f in self.files if not self._test_file(f.path)]

The files listed: the project's own, with its tests if ticked (the dependencies' are only measured when their box is ticked).

def label(self, path: pathlib.Path) -> str:
285    def label(self, path: Path) -> str:
286        """A file's name as listed: its path in the project (or in its dependency)."""
287        label = self._labels.get(path)
288        if label is None:
289            label = self._labels[path] = self._label(path)
290        return label

A file's name as listed: its path in the project (or in its dependency).

def chosen_file(self) -> str | None:
327    def chosen_file(self) -> str | None:
328        """The file chosen in the files' table (its label), if the filter is
329        still that file's."""
330        row = self.file_table.currentRow()
331        item = self.file_table.item(row, 0) if row >= 0 else None
332        label = item.data(Qt.UserRole) if item is not None else None
333        return label if label and label == self.filter_edit.text() else None

The file chosen in the files' table (its label), if the filter is still that file's.

def set_code_font(self, font: PySide6.QtGui.QFont):
381    def set_code_font(self, font: QFont):
382        """The graph's font (the editor's)."""
383        self.flow.set_code_font(font)

The graph's font (the editor's).

staticMetaObject = PySide6.QtCore.QMetaObject("ComplexityPanel" inherits "QWidget": Methods: #33 type=Signal, signature=open_requested(QString,int), parameters=QString, int #34 type=Signal, signature=refresh_requested() )