QT-PyQt-PySide-Custom-Widgets-Pro 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- custom_widgets_pro/__init__.py +73 -0
- custom_widgets_pro/_catalog.py +25 -0
- custom_widgets_pro/_cli.py +132 -0
- custom_widgets_pro/_license.py +419 -0
- custom_widgets_pro/_version.py +14 -0
- custom_widgets_pro/datatable/__init__.py +17 -0
- custom_widgets_pro/datatable/datatable_pro.py +319 -0
- custom_widgets_pro/datatable/export.py +143 -0
- custom_widgets_pro/datatable/frozen_view.py +112 -0
- custom_widgets_pro/datatable/grouping.py +309 -0
- custom_widgets_pro/datatable/pivot.py +100 -0
- custom_widgets_pro/datatable/provider.py +98 -0
- custom_widgets_pro/datatable/virtual_model.py +451 -0
- custom_widgets_pro/widgets/__init__.py +7 -0
- custom_widgets_pro/widgets/charts/QCustomBeeswarm.py +317 -0
- custom_widgets_pro/widgets/charts/QCustomBubbleChart.py +655 -0
- custom_widgets_pro/widgets/charts/QCustomCandlestickChart.py +522 -0
- custom_widgets_pro/widgets/charts/QCustomDivergingBarChart.py +430 -0
- custom_widgets_pro/widgets/charts/QCustomDotMatrix.py +266 -0
- custom_widgets_pro/widgets/charts/QCustomFunnelChart.py +474 -0
- custom_widgets_pro/widgets/charts/QCustomGanttChart.py +376 -0
- custom_widgets_pro/widgets/charts/QCustomHeatmap.py +555 -0
- custom_widgets_pro/widgets/charts/QCustomRadarChart.py +600 -0
- custom_widgets_pro/widgets/charts/QCustomRadialBars.py +441 -0
- custom_widgets_pro/widgets/charts/QCustomRadialLines.py +554 -0
- custom_widgets_pro/widgets/charts/QCustomRangeBarChart.py +504 -0
- custom_widgets_pro/widgets/charts/QCustomSankey.py +539 -0
- custom_widgets_pro/widgets/charts/QCustomScatterChart.py +623 -0
- custom_widgets_pro/widgets/charts/__init__.py +1 -0
- custom_widgets_pro/widgets/data/QCustomCodeEditor.py +391 -0
- custom_widgets_pro/widgets/data/QCustomNodeGraph.py +1030 -0
- custom_widgets_pro/widgets/data/QCustomRichTextEditor.py +189 -0
- custom_widgets_pro/widgets/data/QCustomTableToolbar.py +566 -0
- custom_widgets_pro/widgets/data/__init__.py +1 -0
- custom_widgets_pro/widgets/media/QCustomImageViewer.py +300 -0
- custom_widgets_pro/widgets/media/QCustomMediaGrid.py +197 -0
- custom_widgets_pro/widgets/media/QCustomMediaTimeline.py +607 -0
- custom_widgets_pro/widgets/media/QCustomVideoPlayer.py +326 -0
- custom_widgets_pro/widgets/media/__init__.py +1 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/METADATA +112 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/RECORD +45 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/WHEEL +5 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/entry_points.txt +2 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/licenses/LICENSE +91 -0
- qt_pyqt_pyside_custom_widgets_pro-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""Virtualized (lazy) table model for DataTable Pro.
|
|
6
|
+
|
|
7
|
+
Subclasses the free core's ``QCustomDataTableModel`` so all display formatting,
|
|
8
|
+
alignment and the ``Qt.UserRole`` raw-value sort seam are inherited. It holds
|
|
9
|
+
only the rows fetched so far (in the base's ``_rows`` cache) and pulls more from
|
|
10
|
+
a DataProvider on demand via Qt's ``canFetchMore`` / ``fetchMore`` protocol - so
|
|
11
|
+
scrolling a 100k+ or streaming source loads windows lazily instead of
|
|
12
|
+
materialising everything up front.
|
|
13
|
+
|
|
14
|
+
QSortFilterProxyModel forwards canFetchMore/fetchMore to its source, so this
|
|
15
|
+
model works unchanged inside the free table's model->proxy->view chain (with
|
|
16
|
+
pagination disabled).
|
|
17
|
+
"""
|
|
18
|
+
from qtpy.QtCore import Qt, QModelIndex, Signal
|
|
19
|
+
from qtpy.QtGui import QBrush, QColor, QFont
|
|
20
|
+
|
|
21
|
+
from Custom_Widgets.QCustomDataTable import QCustomDataTableModel
|
|
22
|
+
|
|
23
|
+
from .grouping import GroupingEngine, GroupRow
|
|
24
|
+
|
|
25
|
+
#: Custom item-data role: on a grouped model, returns the ``GroupRow`` for a
|
|
26
|
+
#: group-header row (or None for data rows). Lets the widget detect header
|
|
27
|
+
#: clicks and tests introspect the outline without touching internals.
|
|
28
|
+
GroupRole = Qt.UserRole + 1000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VirtualDataTableModel(QCustomDataTableModel):
|
|
32
|
+
cellEdited = Signal(int, str, object, object) # row, key, old, new
|
|
33
|
+
validationFailed = Signal(int, str, object, str) # row, key, value, message
|
|
34
|
+
|
|
35
|
+
def __init__(self, columns=None, provider=None, chunkSize=200, parent=None):
|
|
36
|
+
super().__init__(columns, [], parent)
|
|
37
|
+
self._provider = None
|
|
38
|
+
self._total = None
|
|
39
|
+
self._exhausted = True
|
|
40
|
+
self._chunk = max(1, int(chunkSize))
|
|
41
|
+
self._sort = None # (column_key, ascending) pushed to the provider
|
|
42
|
+
self._filter = None # free-text filter pushed to the provider
|
|
43
|
+
self._editableKeys = set()
|
|
44
|
+
self._validators = {}
|
|
45
|
+
self._engine = None # GroupingEngine when grouping is active
|
|
46
|
+
self._groupBg = QColor("#f1f5f9") # neutral defaults; token-set by the
|
|
47
|
+
self._groupFg = QColor("#0f172a") # widget via setGroupColors()
|
|
48
|
+
if provider is not None:
|
|
49
|
+
self.setProvider(provider)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
## Inline editing
|
|
53
|
+
# ------------------------------------------------------------------ #
|
|
54
|
+
def setEditableColumns(self, keys):
|
|
55
|
+
self._editableKeys = set(keys or [])
|
|
56
|
+
if self._rows:
|
|
57
|
+
self.dataChanged.emit(self.index(0, 0),
|
|
58
|
+
self.index(self.rowCount() - 1, self.columnCount() - 1))
|
|
59
|
+
|
|
60
|
+
def editableColumns(self):
|
|
61
|
+
return set(self._editableKeys)
|
|
62
|
+
|
|
63
|
+
def setValidator(self, key, fn):
|
|
64
|
+
if fn is None:
|
|
65
|
+
self._validators.pop(key, None)
|
|
66
|
+
else:
|
|
67
|
+
self._validators[key] = fn
|
|
68
|
+
|
|
69
|
+
def rowCount(self, parent=QModelIndex()):
|
|
70
|
+
if parent.isValid():
|
|
71
|
+
return 0
|
|
72
|
+
if self._engine is not None:
|
|
73
|
+
return len(self._engine.visible)
|
|
74
|
+
return super().rowCount(parent)
|
|
75
|
+
|
|
76
|
+
def flags(self, index):
|
|
77
|
+
# Columns are resolved through _dataColumnAt, never by indexing
|
|
78
|
+
# self._columns raw: with setSelectable(True) the view gains a
|
|
79
|
+
# synthetic select column, shifting every data column by one and
|
|
80
|
+
# putting the last view column past len(self._columns). The raw
|
|
81
|
+
# lookup raised IndexError inside a C++ virtual call on every paint
|
|
82
|
+
# of that column — and an exception thrown across the binding there
|
|
83
|
+
# corrupts state and segfaults later, far from this line.
|
|
84
|
+
if not index.isValid():
|
|
85
|
+
return Qt.NoItemFlags
|
|
86
|
+
row, col_idx = index.row(), index.column()
|
|
87
|
+
if not (0 <= col_idx < self.columnCount()):
|
|
88
|
+
return Qt.NoItemFlags
|
|
89
|
+
col = self._dataColumnAt(col_idx) # None for select/actions
|
|
90
|
+
if self._engine is not None:
|
|
91
|
+
if not (0 <= row < len(self._engine.visible)):
|
|
92
|
+
return Qt.NoItemFlags
|
|
93
|
+
entry = self._engine.visible[row]
|
|
94
|
+
if isinstance(entry, GroupRow):
|
|
95
|
+
return Qt.ItemIsEnabled # clickable, not selectable
|
|
96
|
+
if col is None:
|
|
97
|
+
return super().flags(index) # synthetic column
|
|
98
|
+
base = Qt.ItemIsEnabled | Qt.ItemIsSelectable
|
|
99
|
+
if col.key in self._editableKeys:
|
|
100
|
+
return base | (Qt.ItemIsUserCheckable if col.type == "bool"
|
|
101
|
+
else Qt.ItemIsEditable)
|
|
102
|
+
return base
|
|
103
|
+
base = super().flags(index)
|
|
104
|
+
if col is None:
|
|
105
|
+
return base
|
|
106
|
+
if col.key in self._editableKeys:
|
|
107
|
+
if col.type == "bool":
|
|
108
|
+
return base | Qt.ItemIsUserCheckable
|
|
109
|
+
return base | Qt.ItemIsEditable
|
|
110
|
+
return base
|
|
111
|
+
|
|
112
|
+
def data(self, index, role=Qt.DisplayRole):
|
|
113
|
+
if self._engine is not None:
|
|
114
|
+
return self._groupedData(index, role)
|
|
115
|
+
if index.isValid():
|
|
116
|
+
col = self._dataColumnAt(index.column()) # None for select/actions
|
|
117
|
+
if col is not None and col.type == "bool" and col.key in self._editableKeys:
|
|
118
|
+
if role == Qt.CheckStateRole and 0 <= index.row() < len(self._rows):
|
|
119
|
+
return Qt.Checked if self._rows[index.row()].get(col.key) else Qt.Unchecked
|
|
120
|
+
if role in (Qt.DisplayRole, Qt.EditRole):
|
|
121
|
+
return "" # show only the checkbox
|
|
122
|
+
return super().data(index, role)
|
|
123
|
+
|
|
124
|
+
def setData(self, index, value, role=Qt.EditRole):
|
|
125
|
+
if not index.isValid():
|
|
126
|
+
return False
|
|
127
|
+
row, col_idx = index.row(), index.column()
|
|
128
|
+
col = self._dataColumnAt(col_idx) # None for select/actions
|
|
129
|
+
if col is None:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
# Resolve the target row dict (grouped: skip headers; flat: _rows[row]).
|
|
133
|
+
if self._engine is not None:
|
|
134
|
+
if not (0 <= row < len(self._engine.visible)):
|
|
135
|
+
return False
|
|
136
|
+
entry = self._engine.visible[row]
|
|
137
|
+
if isinstance(entry, GroupRow):
|
|
138
|
+
return False
|
|
139
|
+
record = entry
|
|
140
|
+
else:
|
|
141
|
+
if not (0 <= row < len(self._rows)):
|
|
142
|
+
return False
|
|
143
|
+
record = self._rows[row]
|
|
144
|
+
|
|
145
|
+
if col.key not in self._editableKeys:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
if role == Qt.CheckStateRole and col.type == "bool":
|
|
149
|
+
try:
|
|
150
|
+
new = int(value) == int(Qt.Checked)
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
new = bool(value)
|
|
153
|
+
elif role == Qt.EditRole:
|
|
154
|
+
new = self._coerce(col, value)
|
|
155
|
+
else:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
# cellEdited reports the underlying (flat) row index for a stable contract.
|
|
159
|
+
src_row = self._rows.index(record) if record in self._rows else row
|
|
160
|
+
|
|
161
|
+
validator = self._validators.get(col.key)
|
|
162
|
+
if validator is not None:
|
|
163
|
+
result = validator(new)
|
|
164
|
+
if result is not True:
|
|
165
|
+
msg = result if isinstance(result, str) else "invalid value"
|
|
166
|
+
self.validationFailed.emit(src_row, col.key, new, msg)
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
old = record.get(col.key)
|
|
170
|
+
if new == old:
|
|
171
|
+
return True
|
|
172
|
+
record[col.key] = new
|
|
173
|
+
self.dataChanged.emit(index, index,
|
|
174
|
+
[Qt.DisplayRole, Qt.EditRole, Qt.CheckStateRole])
|
|
175
|
+
self.cellEdited.emit(src_row, col.key, old, new)
|
|
176
|
+
if self._engine is not None:
|
|
177
|
+
# an edited value may change a group's aggregates; refresh headers.
|
|
178
|
+
self._engine.recomputeAggregates()
|
|
179
|
+
self.dataChanged.emit(self.index(0, 0),
|
|
180
|
+
self.index(self.rowCount() - 1,
|
|
181
|
+
self.columnCount() - 1))
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------ #
|
|
185
|
+
## Grouping / aggregation
|
|
186
|
+
# ------------------------------------------------------------------ #
|
|
187
|
+
def setGrouping(self, keys, aggregates=None):
|
|
188
|
+
"""Group the currently-loaded rows by ``keys`` (a list of column keys),
|
|
189
|
+
computing the ``aggregates`` spec ({column_key: reducer}) per group.
|
|
190
|
+
Pass an empty ``keys`` to clear grouping and return to the flat model.
|
|
191
|
+
|
|
192
|
+
Grouping operates on the rows loaded so far, so the caller (the widget's
|
|
193
|
+
groupBy) fully materialises the current sorted/filtered result first."""
|
|
194
|
+
keys = list(keys or [])
|
|
195
|
+
self.beginResetModel()
|
|
196
|
+
if not keys:
|
|
197
|
+
self._engine = None
|
|
198
|
+
else:
|
|
199
|
+
titles = {c.key: c.title for c in self._columns}
|
|
200
|
+
engine = GroupingEngine(keys, aggregates or {}, titles=titles)
|
|
201
|
+
engine.setRows(self._rows)
|
|
202
|
+
self._engine = engine
|
|
203
|
+
self.endResetModel()
|
|
204
|
+
|
|
205
|
+
def isGrouped(self):
|
|
206
|
+
return self._engine is not None
|
|
207
|
+
|
|
208
|
+
def groupingEngine(self):
|
|
209
|
+
return self._engine
|
|
210
|
+
|
|
211
|
+
def setGroupColors(self, background, foreground):
|
|
212
|
+
"""Set the group-header background / text colours (QColor or any value
|
|
213
|
+
QColor accepts). The widget derives these from the design tokens."""
|
|
214
|
+
self._groupBg = QColor(background)
|
|
215
|
+
self._groupFg = QColor(foreground)
|
|
216
|
+
if self._engine is not None and self._engine.visible:
|
|
217
|
+
self.dataChanged.emit(self.index(0, 0),
|
|
218
|
+
self.index(self.rowCount() - 1,
|
|
219
|
+
self.columnCount() - 1))
|
|
220
|
+
|
|
221
|
+
def toggleGroupRow(self, row):
|
|
222
|
+
"""Expand/collapse the group whose header is at visible ``row``. Emits
|
|
223
|
+
the minimal insert/remove so the view animates instead of resetting.
|
|
224
|
+
Returns ``(path, expanded)`` or None if ``row`` is not a header."""
|
|
225
|
+
if self._engine is None:
|
|
226
|
+
return None
|
|
227
|
+
vis = self._engine.visible
|
|
228
|
+
if not (0 <= row < len(vis)):
|
|
229
|
+
return None
|
|
230
|
+
entry = vis[row]
|
|
231
|
+
if not isinstance(entry, GroupRow):
|
|
232
|
+
return None
|
|
233
|
+
plan = self._engine.planToggle(entry.path)
|
|
234
|
+
if plan is None:
|
|
235
|
+
return None
|
|
236
|
+
kind, start, count = plan
|
|
237
|
+
if count <= 0:
|
|
238
|
+
self._engine.applyToggle(entry.path)
|
|
239
|
+
elif kind == "remove":
|
|
240
|
+
self.beginRemoveRows(QModelIndex(), start, start + count - 1)
|
|
241
|
+
self._engine.applyToggle(entry.path)
|
|
242
|
+
self.endRemoveRows()
|
|
243
|
+
else:
|
|
244
|
+
self.beginInsertRows(QModelIndex(), start, start + count - 1)
|
|
245
|
+
self._engine.applyToggle(entry.path)
|
|
246
|
+
self.endInsertRows()
|
|
247
|
+
self.dataChanged.emit(self.index(row, 0),
|
|
248
|
+
self.index(row, self.columnCount() - 1))
|
|
249
|
+
return (entry.path, self._engine.isExpanded(entry.path))
|
|
250
|
+
|
|
251
|
+
def setAllGroupsExpanded(self, expanded):
|
|
252
|
+
if self._engine is None:
|
|
253
|
+
return
|
|
254
|
+
self.beginResetModel()
|
|
255
|
+
self._engine.setAllExpanded(bool(expanded))
|
|
256
|
+
self.endResetModel()
|
|
257
|
+
|
|
258
|
+
# ----- grouped data() helpers ----------------------------------------
|
|
259
|
+
def _groupedData(self, index, role):
|
|
260
|
+
if not index.isValid():
|
|
261
|
+
return None
|
|
262
|
+
row, col_idx = index.row(), index.column()
|
|
263
|
+
vis = self._engine.visible
|
|
264
|
+
if not (0 <= row < len(vis)) or not (0 <= col_idx < self.columnCount()):
|
|
265
|
+
return None
|
|
266
|
+
entry = vis[row]
|
|
267
|
+
# None for the synthetic select/actions columns (see flags()).
|
|
268
|
+
column = self._dataColumnAt(col_idx)
|
|
269
|
+
if isinstance(entry, GroupRow):
|
|
270
|
+
return self._groupHeaderData(entry, col_idx, column, role)
|
|
271
|
+
return self._groupChildData(entry, col_idx, column, role)
|
|
272
|
+
|
|
273
|
+
def _groupHeaderData(self, entry, col_idx, column, role):
|
|
274
|
+
if role == GroupRole:
|
|
275
|
+
return entry
|
|
276
|
+
# The outline label lives in the first DATA column — with a select
|
|
277
|
+
# column present that is view column 1, not 0.
|
|
278
|
+
labelCol = self._selOffset()
|
|
279
|
+
if role in (Qt.DisplayRole, Qt.EditRole):
|
|
280
|
+
if col_idx == labelCol:
|
|
281
|
+
indent = " " * entry.depth
|
|
282
|
+
arrow = "▾ " if entry.expanded else "▸ " # down / right
|
|
283
|
+
value = self._displayForKey(entry.key, entry.value)
|
|
284
|
+
return "%s%s%s: %s (%d)" % (indent, arrow, entry.keyTitle,
|
|
285
|
+
value, entry.count)
|
|
286
|
+
if column is not None and column.key in entry.aggregates:
|
|
287
|
+
return self._display(column, entry.aggregates[column.key])
|
|
288
|
+
return ""
|
|
289
|
+
if role == Qt.FontRole:
|
|
290
|
+
font = QFont()
|
|
291
|
+
font.setBold(True)
|
|
292
|
+
return font
|
|
293
|
+
if role == Qt.BackgroundRole:
|
|
294
|
+
return QBrush(self._groupBg)
|
|
295
|
+
if role == Qt.ForegroundRole:
|
|
296
|
+
return QBrush(self._groupFg)
|
|
297
|
+
if role == Qt.TextAlignmentRole:
|
|
298
|
+
if column is None or col_idx == labelCol:
|
|
299
|
+
return int(Qt.AlignLeft | Qt.AlignVCenter)
|
|
300
|
+
align = column.align if column.align is not None else column.defaultAlign()
|
|
301
|
+
return int(align)
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
def _groupChildData(self, record, col_idx, column, role):
|
|
305
|
+
if role == GroupRole:
|
|
306
|
+
return None
|
|
307
|
+
if column is None:
|
|
308
|
+
# Select/actions cells stay inert while grouped: their per-row
|
|
309
|
+
# state is keyed by flat row indices, which grouping reorders.
|
|
310
|
+
return None
|
|
311
|
+
value = record.get(column.key)
|
|
312
|
+
if column.type == "bool" and column.key in self._editableKeys:
|
|
313
|
+
if role == Qt.CheckStateRole:
|
|
314
|
+
return Qt.Checked if value else Qt.Unchecked
|
|
315
|
+
if role in (Qt.DisplayRole, Qt.EditRole):
|
|
316
|
+
return ""
|
|
317
|
+
if role in (Qt.DisplayRole, Qt.EditRole):
|
|
318
|
+
return self._display(column, value)
|
|
319
|
+
if role == Qt.TextAlignmentRole:
|
|
320
|
+
align = column.align if column.align is not None else column.defaultAlign()
|
|
321
|
+
return int(align)
|
|
322
|
+
if role == Qt.UserRole:
|
|
323
|
+
return value
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
def _displayForKey(self, key, value):
|
|
327
|
+
for col in self._columns:
|
|
328
|
+
if col.key == key:
|
|
329
|
+
return self._display(col, value)
|
|
330
|
+
return "" if value is None else str(value)
|
|
331
|
+
|
|
332
|
+
@staticmethod
|
|
333
|
+
def _coerce(col, value):
|
|
334
|
+
if col.type == "number":
|
|
335
|
+
s = str(value).strip()
|
|
336
|
+
try:
|
|
337
|
+
return int(s) if s.lstrip("-").isdigit() else float(s)
|
|
338
|
+
except (TypeError, ValueError):
|
|
339
|
+
return value
|
|
340
|
+
if col.type == "bool":
|
|
341
|
+
return str(value).strip().lower() in ("1", "true", "yes", "y")
|
|
342
|
+
return str(value)
|
|
343
|
+
|
|
344
|
+
# ------------------------------------------------------------------ #
|
|
345
|
+
## Provider wiring
|
|
346
|
+
# ------------------------------------------------------------------ #
|
|
347
|
+
def setProvider(self, provider):
|
|
348
|
+
self._provider = provider
|
|
349
|
+
self._reset()
|
|
350
|
+
|
|
351
|
+
def provider(self):
|
|
352
|
+
return self._provider
|
|
353
|
+
|
|
354
|
+
def _reset(self):
|
|
355
|
+
"""Clear the loaded window and recompute the (filtered) total. Callers
|
|
356
|
+
prime the first window afterwards where a refresh is expected. Grouping
|
|
357
|
+
is dropped here (the row set is being rebuilt); the widget re-applies it
|
|
358
|
+
via _reapplyGrouping after the reload."""
|
|
359
|
+
self._engine = None
|
|
360
|
+
self.beginResetModel()
|
|
361
|
+
self._rows = []
|
|
362
|
+
self._total = (self._provider.totalRowCount(filter=self._filter)
|
|
363
|
+
if self._provider is not None else None)
|
|
364
|
+
self._exhausted = self._provider is None
|
|
365
|
+
self.endResetModel()
|
|
366
|
+
|
|
367
|
+
# ------------------------------------------------------------------ #
|
|
368
|
+
## Server-side sort / filter push-down
|
|
369
|
+
# ------------------------------------------------------------------ #
|
|
370
|
+
def setSort(self, column, order=Qt.AscendingOrder):
|
|
371
|
+
"""Push a sort down to the provider by column index, then reload."""
|
|
372
|
+
if column is None or not (0 <= column < len(self._columns)):
|
|
373
|
+
self._sort = None
|
|
374
|
+
else:
|
|
375
|
+
self._sort = (self._columns[column].key, order == Qt.AscendingOrder)
|
|
376
|
+
self._reset()
|
|
377
|
+
if self.canFetchMore():
|
|
378
|
+
self.fetchMore()
|
|
379
|
+
|
|
380
|
+
def setFilter(self, text):
|
|
381
|
+
"""Push a free-text filter down to the provider, then reload."""
|
|
382
|
+
self._filter = text or None
|
|
383
|
+
self._reset()
|
|
384
|
+
if self.canFetchMore():
|
|
385
|
+
self.fetchMore()
|
|
386
|
+
|
|
387
|
+
def sort(self, column, order=Qt.AscendingOrder):
|
|
388
|
+
# so a directly-bound view's header sort also pushes down
|
|
389
|
+
self.setSort(column, order)
|
|
390
|
+
|
|
391
|
+
def totalRowCount(self):
|
|
392
|
+
"""Total rows in the source (loaded or not), or the loaded count when
|
|
393
|
+
the total is unknown/streaming."""
|
|
394
|
+
return self._total if self._total is not None else len(self._rows)
|
|
395
|
+
|
|
396
|
+
def loadedRowCount(self):
|
|
397
|
+
return len(self._rows)
|
|
398
|
+
|
|
399
|
+
# ------------------------------------------------------------------ #
|
|
400
|
+
## Qt lazy-loading protocol
|
|
401
|
+
# ------------------------------------------------------------------ #
|
|
402
|
+
def canFetchMore(self, parent=QModelIndex()):
|
|
403
|
+
if self._engine is not None:
|
|
404
|
+
return False # grouped rows are fully materialised
|
|
405
|
+
if parent.isValid() or self._provider is None or self._exhausted:
|
|
406
|
+
return False
|
|
407
|
+
if self._total is None: # streaming / unknown total
|
|
408
|
+
return True
|
|
409
|
+
return len(self._rows) < self._total
|
|
410
|
+
|
|
411
|
+
def fetchMore(self, parent=QModelIndex()):
|
|
412
|
+
if not self.canFetchMore(parent):
|
|
413
|
+
return
|
|
414
|
+
offset = len(self._rows)
|
|
415
|
+
batch = list(self._provider.fetch(offset, self._chunk,
|
|
416
|
+
sort=self._sort, filter=self._filter) or [])
|
|
417
|
+
if not batch:
|
|
418
|
+
self._exhausted = True
|
|
419
|
+
return
|
|
420
|
+
self.beginInsertRows(QModelIndex(), offset, offset + len(batch) - 1)
|
|
421
|
+
self._rows.extend(batch)
|
|
422
|
+
self.endInsertRows()
|
|
423
|
+
if self._total is not None and len(self._rows) >= self._total:
|
|
424
|
+
self._exhausted = True
|
|
425
|
+
|
|
426
|
+
def iterAllRows(self, chunk=None):
|
|
427
|
+
"""Yield every row of the current (sorted/filtered) result in windows,
|
|
428
|
+
without materialising all rows or disturbing the loaded view. Used by
|
|
429
|
+
export - 'what you see' = the current sort + filter."""
|
|
430
|
+
if self._provider is None:
|
|
431
|
+
for row in self._rows:
|
|
432
|
+
yield row
|
|
433
|
+
return
|
|
434
|
+
size = chunk or self._chunk
|
|
435
|
+
offset = 0
|
|
436
|
+
while True:
|
|
437
|
+
batch = list(self._provider.fetch(offset, size,
|
|
438
|
+
sort=self._sort, filter=self._filter) or [])
|
|
439
|
+
if not batch:
|
|
440
|
+
return
|
|
441
|
+
for row in batch:
|
|
442
|
+
yield row
|
|
443
|
+
offset += len(batch)
|
|
444
|
+
|
|
445
|
+
def fetchAll(self, cap=None):
|
|
446
|
+
"""Eagerly load everything (or up to ``cap`` rows). For export or small
|
|
447
|
+
sources; defeats virtualization, so use deliberately."""
|
|
448
|
+
while self.canFetchMore():
|
|
449
|
+
self.fetchMore()
|
|
450
|
+
if cap is not None and len(self._rows) >= cap:
|
|
451
|
+
break
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Pro-only widgets.
|
|
2
|
+
|
|
3
|
+
These ship compiled in the Pro wheel and are NOT in the free GPLv3 package.
|
|
4
|
+
The free package keeps a stub at the same import path so Qt Designer .ui files
|
|
5
|
+
that name Custom_Widgets.<Widget> still resolve, and so a free user gets an
|
|
6
|
+
explanation instead of ModuleNotFoundError.
|
|
7
|
+
"""
|