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