Coverage for vibefpmgui/metrics_table.py: 92%
131 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""A sortable table of rows of objects, drawn on demand (Qt asks for the
2cells on view, not all of them): the Complexity tab's procedures and files.
4A `QTableWidget` makes an item for each cell: a big project's 9000
5procedures were 90 000 items, made again at each refill (more than half a
6second). Here a row is an object (a procedure, a file) and each column says
7how to show it; tooltips are made when asked for.
9It has the few `QTableWidget` calls the panel uses (`item`, `rowCount`,
10`currentRow`, `setCurrentCell`, `cellClicked`, `currentCellChanged`…).
11"""
13from __future__ import annotations
15from collections.abc import Callable
16from typing import Any, NamedTuple
18from qtpy.QtCore import QAbstractTableModel, QModelIndex, QRect, Qt, Signal
19from qtpy.QtGui import QBrush, QColor
20from qtpy.QtWidgets import QTableView
23def _role(role) -> int:
24 return int(getattr(role, "value", role))
27# The roles as plain numbers: data() is asked for every cell and role, and
28# comparing a number with one of Qt's enums is ~100 times slower (a Python Enum).
29DISPLAY, FOREGROUND, FONT, DECORATION, TOOLTIP, USER = (
30 _role(r) for r in (Qt.DisplayRole, Qt.ForegroundRole, Qt.FontRole, Qt.DecorationRole, Qt.ToolTipRole,
31 Qt.UserRole))
32SAMPLED_ROWS = 200 # rows looked at to size a column to its contents (Qt's default: 1000)
35class Column(NamedTuple):
36 title: str
37 text: Callable[[Any], str] # a row's text in it
38 value: Callable[[Any], Any] | None = None # what it's sorted by (None: its text)
39 tip: str = "" # the title's tooltip
42class RowStyle(NamedTuple):
43 """How rows look and what they hold, apart from their text."""
44 color: Callable[[Any, int], QColor | None] = lambda row, column: None
45 bold: Callable[[Any, int], bool] = lambda row, column: False
46 icon: Callable[[Any], Any] | None = None # the first column's icon (see icon_column)
47 tip: Callable[[Any], str] | None = None
48 data: Callable[[Any, int], Any] | None = None # (row, role) -> a value for that role, or None
49 icon_column: int = 0
52class RowModel(QAbstractTableModel):
53 def __init__(self, columns: list[Column], style: RowStyle, parent=None):
54 super().__init__(parent)
55 self.columns = columns
56 self.style = style
57 self.rows: list = []
58 self._sorted: tuple[int, Qt.SortOrder] | None = None
59 self.bold_font = None # (set by the table)
61 def set_rows(self, rows: list):
62 self.beginResetModel()
63 self.rows = list(rows)
64 if self._sorted is not None: 64 ↛ 66line 64 didn't jump to line 66 because the condition on line 64 was always true
65 self._sort_rows(*self._sorted)
66 self.endResetModel()
68 def rowCount(self, parent=QModelIndex()): # noqa: B008 - Qt's signature
69 return 0 if parent.isValid() else len(self.rows)
71 def columnCount(self, parent=QModelIndex()): # noqa: B008
72 return 0 if parent.isValid() else len(self.columns)
74 def headerData(self, section, orientation, role=DISPLAY):
75 role = _role(role)
76 if orientation != Qt.Horizontal or not 0 <= section < len(self.columns): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 return None
78 if role == DISPLAY:
79 return self.columns[section].title
80 if role == TOOLTIP: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true
81 return self.columns[section].tip or None
82 return None
84 def data(self, index, role=DISPLAY):
85 role = _role(role)
86 if role not in (DISPLAY, FOREGROUND, FONT, DECORATION, TOOLTIP) and role < USER:
87 return None # (alignment, size hints…: Qt's defaults)
88 if not index.isValid(): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 return None
90 row, column = self.rows[index.row()], index.column()
91 style = self.style
92 if role == DISPLAY:
93 return self.columns[column].text(row)
94 if role == FOREGROUND:
95 color = style.color(row, column)
96 return QBrush(color) if color is not None else None
97 if role == FONT:
98 return self.bold_font if style.bold(row, column) else None
99 if role == DECORATION:
100 return style.icon(row) if style.icon is not None and column == style.icon_column else None
101 if role == TOOLTIP: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true
102 return style.tip(row) if style.tip is not None else None
103 if style.data is not None: 103 ↛ 105line 103 didn't jump to line 105 because the condition on line 103 was always true
104 return style.data(row, role)
105 return None
107 def sort(self, column, order=Qt.AscendingOrder):
108 self._sorted = (column, order)
109 self.layoutAboutToBeChanged.emit()
110 old = self.persistentIndexList() # (the current row, the selection: they follow their rows)
111 old_rows = [self.rows[i.row()] for i in old]
112 self._sort_rows(column, order)
113 place = {id(row): n for n, row in enumerate(self.rows)}
114 self.changePersistentIndexList(old, [self.index(place[id(row)], i.column()) for row, i in zip(old_rows, old,
115 strict=True)])
116 self.layoutChanged.emit()
118 def _sort_rows(self, column: int, order):
119 spec = self.columns[column]
120 key = spec.value or spec.text
121 self.rows.sort(key=key, reverse=order == Qt.DescendingOrder) # (stable: ties keep their order)
124class Cell:
125 """A cell, as `QTableWidget.item` gives it (what the panel and its tests ask of one)."""
127 def __init__(self, model: RowModel, index):
128 self.model, self.index = model, index
130 def text(self) -> str:
131 return self.model.data(self.index, DISPLAY) or ""
133 def data(self, role):
134 return self.model.data(self.index, role)
136 def foreground(self) -> QBrush:
137 return self.model.data(self.index, FOREGROUND) or QBrush()
139 def toolTip(self) -> str:
140 return self.model.data(self.index, TOOLTIP) or ""
143class _Title:
144 def __init__(self, text: str):
145 self._text = text
147 def text(self) -> str:
148 return self._text
151class MetricsTable(QTableView):
152 """A `QTableView` of a `RowModel`, with `QTableWidget`'s cell signals."""
154 cellClicked = Signal(int, int)
155 currentCellChanged = Signal(int, int, int, int) # row, column, previous row, previous column
157 def __init__(self, columns: list[Column], style: RowStyle, parent=None):
158 super().__init__(parent)
159 self.row_model = RowModel(columns, style, self)
160 self.setModel(self.row_model)
161 self.verticalHeader().setResizeContentsPrecision(SAMPLED_ROWS)
162 self.horizontalHeader().setResizeContentsPrecision(SAMPLED_ROWS)
163 font = self.font()
164 font.setBold(True)
165 self.row_model.bold_font = font
166 self.clicked.connect(lambda index: self.cellClicked.emit(index.row(), index.column()))
167 self.selectionModel().currentChanged.connect(
168 lambda now, before: self.currentCellChanged.emit(now.row(), now.column(), before.row(), before.column()))
170 def set_rows(self, rows: list):
171 self.row_model.set_rows(rows)
173 def rows(self) -> list:
174 """The rows, in the order shown."""
175 return self.row_model.rows
177 def rowCount(self) -> int:
178 return len(self.row_model.rows)
180 def item(self, row: int, column: int) -> Cell | None:
181 index = self.row_model.index(row, column)
182 return Cell(self.row_model, index) if index.isValid() else None
184 def currentRow(self) -> int:
185 return self.currentIndex().row()
187 def setCurrentCell(self, row: int, column: int):
188 self.setCurrentIndex(self.row_model.index(row, column) if row >= 0 else QModelIndex())
190 def horizontalHeaderItem(self, column: int) -> _Title:
191 return _Title(self.row_model.columns[column].title)
193 def visualItemRect(self, cell: Cell) -> QRect:
194 return self.visualRect(cell.index)