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

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. 

3 

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. 

8 

9It has the few `QTableWidget` calls the panel uses (`item`, `rowCount`, 

10`currentRow`, `setCurrentCell`, `cellClicked`, `currentCellChanged`…). 

11""" 

12 

13from __future__ import annotations 

14 

15from collections.abc import Callable 

16from typing import Any, NamedTuple 

17 

18from qtpy.QtCore import QAbstractTableModel, QModelIndex, QRect, Qt, Signal 

19from qtpy.QtGui import QBrush, QColor 

20from qtpy.QtWidgets import QTableView 

21 

22 

23def _role(role) -> int: 

24 return int(getattr(role, "value", role)) 

25 

26 

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) 

33 

34 

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 

40 

41 

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 

50 

51 

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) 

60 

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

67 

68 def rowCount(self, parent=QModelIndex()): # noqa: B008 - Qt's signature 

69 return 0 if parent.isValid() else len(self.rows) 

70 

71 def columnCount(self, parent=QModelIndex()): # noqa: B008 

72 return 0 if parent.isValid() else len(self.columns) 

73 

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 

83 

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 

106 

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

117 

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) 

122 

123 

124class Cell: 

125 """A cell, as `QTableWidget.item` gives it (what the panel and its tests ask of one).""" 

126 

127 def __init__(self, model: RowModel, index): 

128 self.model, self.index = model, index 

129 

130 def text(self) -> str: 

131 return self.model.data(self.index, DISPLAY) or "" 

132 

133 def data(self, role): 

134 return self.model.data(self.index, role) 

135 

136 def foreground(self) -> QBrush: 

137 return self.model.data(self.index, FOREGROUND) or QBrush() 

138 

139 def toolTip(self) -> str: 

140 return self.model.data(self.index, TOOLTIP) or "" 

141 

142 

143class _Title: 

144 def __init__(self, text: str): 

145 self._text = text 

146 

147 def text(self) -> str: 

148 return self._text 

149 

150 

151class MetricsTable(QTableView): 

152 """A `QTableView` of a `RowModel`, with `QTableWidget`'s cell signals.""" 

153 

154 cellClicked = Signal(int, int) 

155 currentCellChanged = Signal(int, int, int, int) # row, column, previous row, previous column 

156 

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

169 

170 def set_rows(self, rows: list): 

171 self.row_model.set_rows(rows) 

172 

173 def rows(self) -> list: 

174 """The rows, in the order shown.""" 

175 return self.row_model.rows 

176 

177 def rowCount(self) -> int: 

178 return len(self.row_model.rows) 

179 

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 

183 

184 def currentRow(self) -> int: 

185 return self.currentIndex().row() 

186 

187 def setCurrentCell(self, row: int, column: int): 

188 self.setCurrentIndex(self.row_model.index(row, column) if row >= 0 else QModelIndex()) 

189 

190 def horizontalHeaderItem(self, column: int) -> _Title: 

191 return _Title(self.row_model.columns[column].title) 

192 

193 def visualItemRect(self, cell: Cell) -> QRect: 

194 return self.visualRect(cell.index)