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,319 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""QCustomDataTablePro - the commercial data grid.
|
|
6
|
+
|
|
7
|
+
Extends the free-core ``QCustomDataTable`` through its stable Pro contract:
|
|
8
|
+
the ``_createModel`` / ``_createView`` factory seams and the model's
|
|
9
|
+
``Qt.UserRole`` raw-value seam. Free examples/tutorials transfer directly -
|
|
10
|
+
"swap the class, gain the features".
|
|
11
|
+
|
|
12
|
+
SCAFFOLD: this currently inherits the free table's behaviour. The Pro
|
|
13
|
+
capabilities (virtualization, frozen columns, grouping/pivot, inline editing,
|
|
14
|
+
server-side loading, CSV/XLSX export) are implemented on the seams below. See
|
|
15
|
+
the free-core repo: docs/design/datatable-pro-spec.md.
|
|
16
|
+
|
|
17
|
+
Contains ONLY original code - no bundled third-party assets. Export uses the
|
|
18
|
+
Python stdlib (csv, zipfile+xml for XLSX); indicators are painted, not shipped.
|
|
19
|
+
"""
|
|
20
|
+
from qtpy.QtCore import Qt, Signal
|
|
21
|
+
from qtpy.QtGui import QColor, QPalette
|
|
22
|
+
|
|
23
|
+
from Custom_Widgets.QCustomDataTable import QCustomDataTable
|
|
24
|
+
from qtpy.QtWidgets import QAbstractItemView
|
|
25
|
+
|
|
26
|
+
from .._catalog import register
|
|
27
|
+
from .virtual_model import VirtualDataTableModel, GroupRole
|
|
28
|
+
from .provider import DataProvider, ListDataProvider, CallableDataProvider
|
|
29
|
+
from .frozen_view import FrozenHostView
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register
|
|
33
|
+
class QCustomDataTablePro(QCustomDataTable):
|
|
34
|
+
cellEdited = Signal(int, str, object, object) # row, key, old, new
|
|
35
|
+
validationFailed = Signal(int, str, object, str) # row, key, value, message
|
|
36
|
+
groupToggled = Signal(object, bool) # group path, expanded
|
|
37
|
+
|
|
38
|
+
__catalog__ = {
|
|
39
|
+
"name": "QCustomDataTablePro",
|
|
40
|
+
"extends": "QCustomDataTable",
|
|
41
|
+
"edition": "pro",
|
|
42
|
+
"props": {
|
|
43
|
+
"virtualized": {"type": "bool", "default": True},
|
|
44
|
+
"editable": {"type": "bool", "default": False},
|
|
45
|
+
"pinnedColumns": {"type": "list", "default": []},
|
|
46
|
+
"groupBy": {"type": "list", "default": []},
|
|
47
|
+
},
|
|
48
|
+
"signals": ["cellEdited", "groupToggled"],
|
|
49
|
+
"capabilities": ["virtualization", "frozen-columns", "grouping",
|
|
50
|
+
"pivot", "inline-edit", "server-side", "export-csv",
|
|
51
|
+
"export-xlsx"],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def __init__(self, parent=None, chunkSize=200):
|
|
55
|
+
self._chunkSize = int(chunkSize)
|
|
56
|
+
self._sortCol = -1
|
|
57
|
+
self._sortAsc = True
|
|
58
|
+
self._groupKeys = []
|
|
59
|
+
self._aggregates = {}
|
|
60
|
+
self._prePivot = None # (columns, provider) snapshot before pivot
|
|
61
|
+
self._pivotActive = False
|
|
62
|
+
super().__init__(parent)
|
|
63
|
+
# Virtualization: disable pagination so the view fetches lazily from the
|
|
64
|
+
# model (the sort/filter proxy forwards canFetchMore/fetchMore).
|
|
65
|
+
self.showPagination = False
|
|
66
|
+
# The view's built-in (client-side) sort stays OFF - it would only order
|
|
67
|
+
# the loaded window. Sort/filter are pushed DOWN to the provider instead;
|
|
68
|
+
# we drive the header ourselves so the arrow still works.
|
|
69
|
+
self.sortable = False
|
|
70
|
+
header = self._view.horizontalHeader()
|
|
71
|
+
header.setSectionsClickable(True)
|
|
72
|
+
header.setSortIndicatorShown(True)
|
|
73
|
+
header.sectionClicked.connect(self._onHeaderSortPushdown)
|
|
74
|
+
|
|
75
|
+
# inline editing: enable triggers on the view and re-emit model edits
|
|
76
|
+
self._view.setEditTriggers(QAbstractItemView.DoubleClicked
|
|
77
|
+
| QAbstractItemView.EditKeyPressed
|
|
78
|
+
| QAbstractItemView.AnyKeyPressed)
|
|
79
|
+
self._model.cellEdited.connect(self.cellEdited)
|
|
80
|
+
self._model.validationFailed.connect(self.validationFailed)
|
|
81
|
+
|
|
82
|
+
# grouping: clicking a group-header row toggles expand/collapse
|
|
83
|
+
self._view.clicked.connect(self._onProClicked)
|
|
84
|
+
|
|
85
|
+
# -- Server-side sort / filter (push-down) ------------------------------
|
|
86
|
+
def _onHeaderSortPushdown(self, column):
|
|
87
|
+
if column == self._sortCol:
|
|
88
|
+
self._sortAsc = not self._sortAsc
|
|
89
|
+
else:
|
|
90
|
+
self._sortCol, self._sortAsc = column, True
|
|
91
|
+
self.sortBy(column, Qt.AscendingOrder if self._sortAsc else Qt.DescendingOrder)
|
|
92
|
+
|
|
93
|
+
def sortBy(self, column, order=Qt.AscendingOrder):
|
|
94
|
+
"""Push a sort down to the provider (overrides the free client-side sort)."""
|
|
95
|
+
self._sortCol = column
|
|
96
|
+
self._sortAsc = (order == Qt.AscendingOrder)
|
|
97
|
+
self._view.horizontalHeader().setSortIndicator(column, order)
|
|
98
|
+
self._model.setSort(column, order)
|
|
99
|
+
self._reapplyGrouping()
|
|
100
|
+
self.sortChanged.emit(column, order)
|
|
101
|
+
|
|
102
|
+
def setFilterText(self, text):
|
|
103
|
+
"""Push a free-text filter down to the provider (overrides the free
|
|
104
|
+
client-side proxy filter)."""
|
|
105
|
+
self._model.setFilter(text)
|
|
106
|
+
self._reapplyGrouping()
|
|
107
|
+
|
|
108
|
+
def _reapplyGrouping(self):
|
|
109
|
+
"""After a sort/filter reload, re-materialise and re-group so the
|
|
110
|
+
outline reflects the new result set."""
|
|
111
|
+
if self._groupKeys:
|
|
112
|
+
self._model.fetchAll()
|
|
113
|
+
self._model.setGrouping(self._groupKeys, self._aggregates)
|
|
114
|
+
|
|
115
|
+
# -- Pro extension seams (override the free factories) ------------------
|
|
116
|
+
def _createModel(self):
|
|
117
|
+
return VirtualDataTableModel(chunkSize=getattr(self, "_chunkSize", 200),
|
|
118
|
+
parent=self)
|
|
119
|
+
|
|
120
|
+
def _createView(self):
|
|
121
|
+
return FrozenHostView(self)
|
|
122
|
+
|
|
123
|
+
# -- Data API (virtualized) --------------------------------------------
|
|
124
|
+
def setDataProvider(self, provider):
|
|
125
|
+
"""Attach a lazy DataProvider (row windows + optional total). Primes the
|
|
126
|
+
first window so the view shows content immediately; scrolling loads the
|
|
127
|
+
rest."""
|
|
128
|
+
if not isinstance(provider, DataProvider):
|
|
129
|
+
raise TypeError("expected a DataProvider, got %r" % type(provider))
|
|
130
|
+
self._model.setProvider(provider)
|
|
131
|
+
if self._model.canFetchMore():
|
|
132
|
+
self._model.fetchMore()
|
|
133
|
+
|
|
134
|
+
def setData(self, rows):
|
|
135
|
+
"""Feed an in-memory list through the virtualized path."""
|
|
136
|
+
self.setDataProvider(ListDataProvider(rows))
|
|
137
|
+
|
|
138
|
+
setRows = setData
|
|
139
|
+
|
|
140
|
+
def setFetchCallback(self, fetch_fn, total=None):
|
|
141
|
+
"""Convenience: virtualize over ``fetch_fn(offset, limit) -> list``."""
|
|
142
|
+
self.setDataProvider(CallableDataProvider(fetch_fn, total=total))
|
|
143
|
+
|
|
144
|
+
def loadedRowCount(self):
|
|
145
|
+
return self._model.loadedRowCount()
|
|
146
|
+
|
|
147
|
+
def totalRowCount(self):
|
|
148
|
+
return self._model.totalRowCount()
|
|
149
|
+
|
|
150
|
+
# -- Pro API surface (further stubs; per datatable-pro-spec.md) ---------
|
|
151
|
+
def setFrozenColumnCount(self, count):
|
|
152
|
+
"""Freeze (pin) the leftmost `count` columns so they stay visible while
|
|
153
|
+
the rest scroll horizontally."""
|
|
154
|
+
self._view.setFrozenColumnCount(count)
|
|
155
|
+
|
|
156
|
+
def frozenColumnCount(self):
|
|
157
|
+
return self._view.frozenColumnCount()
|
|
158
|
+
|
|
159
|
+
def pinColumns(self, keys):
|
|
160
|
+
"""Pin the columns for `keys`. Pinned columns are the leftmost ones, so
|
|
161
|
+
this freezes every column up to (and including) the right-most key."""
|
|
162
|
+
cols = self._model.columns()
|
|
163
|
+
index = {c.key: i for i, c in enumerate(cols)}
|
|
164
|
+
indices = [index[k] for k in keys if k in index]
|
|
165
|
+
self.setFrozenColumnCount(max(indices) + 1 if indices else 0)
|
|
166
|
+
|
|
167
|
+
# -- Grouping / aggregation --------------------------------------------
|
|
168
|
+
def groupBy(self, keys, aggregates=None):
|
|
169
|
+
"""Group rows by one or more column ``keys`` (outer -> inner), with an
|
|
170
|
+
optional ``aggregates`` spec ({column_key: reducer}) shown on each group
|
|
171
|
+
header. ``reducer`` is a name (sum/avg/count/min/max/first/last) or a
|
|
172
|
+
callable ``fn(values) -> result``.
|
|
173
|
+
|
|
174
|
+
Grouping aggregates over the whole current result, so this materialises
|
|
175
|
+
the current sorted/filtered rows (leaving virtualization only while
|
|
176
|
+
grouped); ``groupBy([])`` clears grouping and restores lazy loading."""
|
|
177
|
+
keys = list(keys or [])
|
|
178
|
+
if self._pivotActive:
|
|
179
|
+
self.clearPivot() # grouping and pivot are exclusive
|
|
180
|
+
# aggregate spec may also be declared on the columns (col.aggregate).
|
|
181
|
+
agg = dict(aggregates or {})
|
|
182
|
+
for col in self._model.columns():
|
|
183
|
+
spec = getattr(col, "aggregate", None)
|
|
184
|
+
if spec is not None and col.key not in agg:
|
|
185
|
+
agg[col.key] = spec
|
|
186
|
+
self._groupKeys = keys
|
|
187
|
+
self._aggregates = agg
|
|
188
|
+
if keys:
|
|
189
|
+
self._model.fetchAll()
|
|
190
|
+
self._applyGroupColors()
|
|
191
|
+
self._model.setGrouping(keys, agg)
|
|
192
|
+
|
|
193
|
+
def clearGrouping(self):
|
|
194
|
+
"""Remove grouping and return to the flat, virtualized view."""
|
|
195
|
+
self.groupBy([])
|
|
196
|
+
|
|
197
|
+
def groupKeys(self):
|
|
198
|
+
return list(self._groupKeys)
|
|
199
|
+
|
|
200
|
+
def isGrouped(self):
|
|
201
|
+
return self._model.isGrouped()
|
|
202
|
+
|
|
203
|
+
def expandAllGroups(self):
|
|
204
|
+
self._model.setAllGroupsExpanded(True)
|
|
205
|
+
|
|
206
|
+
def collapseAllGroups(self):
|
|
207
|
+
self._model.setAllGroupsExpanded(False)
|
|
208
|
+
|
|
209
|
+
def toggleGroup(self, row):
|
|
210
|
+
"""Toggle the group header at source ``row`` (as returned by GroupRole).
|
|
211
|
+
Prefer clicking; this is the programmatic hook."""
|
|
212
|
+
result = self._model.toggleGroupRow(row)
|
|
213
|
+
if result is not None:
|
|
214
|
+
self.groupToggled.emit(*result)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
def _onProClicked(self, viewIndex):
|
|
218
|
+
if not self._model.isGrouped():
|
|
219
|
+
return
|
|
220
|
+
src = self._sortFilter.mapToSource(viewIndex)
|
|
221
|
+
header = self._model.data(self._model.index(src.row(), 0), GroupRole)
|
|
222
|
+
if header is not None:
|
|
223
|
+
self.toggleGroup(src.row())
|
|
224
|
+
|
|
225
|
+
def _applyGroupColors(self):
|
|
226
|
+
"""Derive group-header colours from the design tokens, following the
|
|
227
|
+
widget's light/dark palette."""
|
|
228
|
+
try:
|
|
229
|
+
from Custom_Widgets.JSonStyles.tokens import DesignTokens
|
|
230
|
+
except Exception:
|
|
231
|
+
return
|
|
232
|
+
base = self.palette().color(QPalette.Base)
|
|
233
|
+
theme = "dark" if base.lightness() < 128 else "light"
|
|
234
|
+
tokens = DesignTokens(theme=theme)
|
|
235
|
+
self._model.setGroupColors(QColor(tokens.role("surface-muted")),
|
|
236
|
+
QColor(tokens.role("on-surface")))
|
|
237
|
+
|
|
238
|
+
# -- Pivot (cross-tab) -------------------------------------------------
|
|
239
|
+
def pivot(self, index, columns, values, aggfunc="sum", totals=True,
|
|
240
|
+
fill=None):
|
|
241
|
+
"""Reshape the current result into a pivot (cross-tab) table.
|
|
242
|
+
|
|
243
|
+
index row-dimension key (or list of keys) - one output row per
|
|
244
|
+
distinct combination.
|
|
245
|
+
columns a single key whose distinct values become output columns.
|
|
246
|
+
values the key aggregated into each cell.
|
|
247
|
+
aggfunc reducer name (sum/avg/count/min/max/first/last) or a callable.
|
|
248
|
+
totals add a trailing per-row total column.
|
|
249
|
+
|
|
250
|
+
Snapshots the pre-pivot table so clearPivot() restores it. Pivot and
|
|
251
|
+
grouping are exclusive."""
|
|
252
|
+
from .pivot import pivot_table
|
|
253
|
+
if self._pivotActive:
|
|
254
|
+
self.clearPivot() # always pivot from the flat source
|
|
255
|
+
if self._groupKeys:
|
|
256
|
+
self.groupBy([]) # drop grouping first
|
|
257
|
+
self._model.fetchAll() # materialise current sort/filter
|
|
258
|
+
source_rows = self._model.rows()
|
|
259
|
+
self._prePivot = (self._model.columns(), self._model.provider())
|
|
260
|
+
|
|
261
|
+
col_defs, result = pivot_table(source_rows, index, columns, values,
|
|
262
|
+
agg=aggfunc, totals=totals, fill=fill)
|
|
263
|
+
# keep the index columns' original titles / types
|
|
264
|
+
original = {c.key: c for c in self._prePivot[0]}
|
|
265
|
+
for cd in col_defs:
|
|
266
|
+
src = original.get(cd["key"])
|
|
267
|
+
if src is not None:
|
|
268
|
+
cd["title"] = src.title
|
|
269
|
+
cd["type"] = src.type
|
|
270
|
+
self._pivotActive = True
|
|
271
|
+
self.setColumns(col_defs)
|
|
272
|
+
self.setData(result)
|
|
273
|
+
|
|
274
|
+
def clearPivot(self):
|
|
275
|
+
"""Restore the table to its pre-pivot columns and data source."""
|
|
276
|
+
if self._prePivot is None:
|
|
277
|
+
return
|
|
278
|
+
columns, provider = self._prePivot
|
|
279
|
+
self._prePivot = None
|
|
280
|
+
self._pivotActive = False
|
|
281
|
+
self.setColumns(columns)
|
|
282
|
+
if provider is not None:
|
|
283
|
+
self.setDataProvider(provider)
|
|
284
|
+
else:
|
|
285
|
+
self.setData([])
|
|
286
|
+
|
|
287
|
+
def isPivoted(self):
|
|
288
|
+
return self._pivotActive
|
|
289
|
+
|
|
290
|
+
def setEditableColumns(self, keys):
|
|
291
|
+
"""Make the given column keys editable (others stay read-only)."""
|
|
292
|
+
self._model.setEditableColumns(keys)
|
|
293
|
+
|
|
294
|
+
def setEditable(self, editable=True):
|
|
295
|
+
"""Convenience: True -> all columns editable, False -> none, or pass a
|
|
296
|
+
list of column keys."""
|
|
297
|
+
if editable is True:
|
|
298
|
+
keys = [c.key for c in self._model.columns()]
|
|
299
|
+
elif editable is False:
|
|
300
|
+
keys = []
|
|
301
|
+
else:
|
|
302
|
+
keys = list(editable)
|
|
303
|
+
self._model.setEditableColumns(keys)
|
|
304
|
+
|
|
305
|
+
def setColumnValidator(self, key, fn):
|
|
306
|
+
"""Set a validator ``fn(value) -> True | error_message`` for a column;
|
|
307
|
+
rejected edits emit validationFailed and are not written."""
|
|
308
|
+
self._model.setValidator(key, fn)
|
|
309
|
+
|
|
310
|
+
def editableColumns(self):
|
|
311
|
+
return self._model.editableColumns()
|
|
312
|
+
|
|
313
|
+
def exportTo(self, path, fmt=None, sheetName="Sheet1"):
|
|
314
|
+
"""Export the current result (respecting the active sort + filter) to
|
|
315
|
+
.csv or .xlsx using the pure-stdlib writer. Rows stream from the
|
|
316
|
+
provider, so a large export is not held in memory."""
|
|
317
|
+
from .export import export_table
|
|
318
|
+
return export_table(path, self._model.columns(),
|
|
319
|
+
self._model.iterAllRows(), fmt=fmt, sheet_name=sheetName)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""CSV / XLSX export for DataTable Pro - pure standard library.
|
|
6
|
+
|
|
7
|
+
CSV uses the stdlib ``csv`` module. XLSX is written directly: an .xlsx file is
|
|
8
|
+
just a ZIP of XML parts, so we emit the minimal Office Open XML package with
|
|
9
|
+
``zipfile`` + string XML. No third-party dependency (no openpyxl), matching the
|
|
10
|
+
Pro rule that this package bundles only original code.
|
|
11
|
+
|
|
12
|
+
Rows are streamed - the worksheet is written straight into the zip entry, so a
|
|
13
|
+
large export never materialises the whole sheet in memory.
|
|
14
|
+
"""
|
|
15
|
+
import csv
|
|
16
|
+
import zipfile
|
|
17
|
+
from xml.sax.saxutils import escape
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cell_value(column, row):
|
|
21
|
+
"""Raw value for a column key in a row (preserves numeric types)."""
|
|
22
|
+
return row.get(column.key)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------- #
|
|
26
|
+
## CSV
|
|
27
|
+
# ---------------------------------------------------------------------- #
|
|
28
|
+
def export_csv(path, columns, rows):
|
|
29
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
30
|
+
writer = csv.writer(fh)
|
|
31
|
+
writer.writerow([c.title for c in columns])
|
|
32
|
+
for row in rows:
|
|
33
|
+
writer.writerow([_csv_cell(_cell_value(c, row)) for c in columns])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _csv_cell(value):
|
|
37
|
+
if value is None:
|
|
38
|
+
return ""
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return "TRUE" if value else "FALSE"
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------- #
|
|
45
|
+
## XLSX (minimal Office Open XML, streamed)
|
|
46
|
+
# ---------------------------------------------------------------------- #
|
|
47
|
+
_CONTENT_TYPES = (
|
|
48
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
49
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
50
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
51
|
+
'<Default Extension="xml" ContentType="application/xml"/>'
|
|
52
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
53
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
54
|
+
'</Types>')
|
|
55
|
+
|
|
56
|
+
_ROOT_RELS = (
|
|
57
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
58
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
59
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|
60
|
+
'</Relationships>')
|
|
61
|
+
|
|
62
|
+
_WORKBOOK_RELS = (
|
|
63
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
64
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
65
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
|
66
|
+
'</Relationships>')
|
|
67
|
+
|
|
68
|
+
_SHEET_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sanitize_sheet_name(name):
|
|
72
|
+
for ch in "\\/?*[]:":
|
|
73
|
+
name = name.replace(ch, " ")
|
|
74
|
+
return (name.strip() or "Sheet1")[:31]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _workbook_xml(sheet_name):
|
|
78
|
+
return (
|
|
79
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
80
|
+
'<workbook xmlns="%s" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
81
|
+
'<sheets><sheet name="%s" sheetId="1" r:id="rId1"/></sheets>'
|
|
82
|
+
'</workbook>' % (_SHEET_NS, escape(sheet_name)))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _col_letter(index):
|
|
86
|
+
"""0-based column index -> A, B, ... Z, AA, ..."""
|
|
87
|
+
letters = ""
|
|
88
|
+
index += 1
|
|
89
|
+
while index:
|
|
90
|
+
index, rem = divmod(index - 1, 26)
|
|
91
|
+
letters = chr(65 + rem) + letters
|
|
92
|
+
return letters
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cell_xml(ref, value):
|
|
96
|
+
if value is None or value == "":
|
|
97
|
+
return '<c r="%s"/>' % ref
|
|
98
|
+
if isinstance(value, bool):
|
|
99
|
+
return '<c r="%s" t="b"><v>%d</v></c>' % (ref, 1 if value else 0)
|
|
100
|
+
if isinstance(value, (int, float)):
|
|
101
|
+
return '<c r="%s"><v>%s</v></c>' % (ref, value)
|
|
102
|
+
return ('<c r="%s" t="inlineStr"><is><t xml:space="preserve">%s</t></is></c>'
|
|
103
|
+
% (ref, escape(str(value))))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _row_xml(rownum, values):
|
|
107
|
+
cells = "".join(_cell_xml("%s%d" % (_col_letter(i), rownum), v)
|
|
108
|
+
for i, v in enumerate(values))
|
|
109
|
+
return '<row r="%d">%s</row>' % (rownum, cells)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def export_xlsx(path, columns, rows, sheet_name="Sheet1"):
|
|
113
|
+
sheet_name = _sanitize_sheet_name(sheet_name)
|
|
114
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
115
|
+
zf.writestr("[Content_Types].xml", _CONTENT_TYPES)
|
|
116
|
+
zf.writestr("_rels/.rels", _ROOT_RELS)
|
|
117
|
+
zf.writestr("xl/workbook.xml", _workbook_xml(sheet_name))
|
|
118
|
+
zf.writestr("xl/_rels/workbook.xml.rels", _WORKBOOK_RELS)
|
|
119
|
+
with zf.open("xl/worksheets/sheet1.xml", "w") as sheet:
|
|
120
|
+
def w(text):
|
|
121
|
+
sheet.write(text.encode("utf-8"))
|
|
122
|
+
w('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
|
|
123
|
+
w('<worksheet xmlns="%s"><sheetData>' % _SHEET_NS)
|
|
124
|
+
w(_row_xml(1, [c.title for c in columns])) # header
|
|
125
|
+
rownum = 2
|
|
126
|
+
for row in rows:
|
|
127
|
+
w(_row_xml(rownum, [_cell_value(c, row) for c in columns]))
|
|
128
|
+
rownum += 1
|
|
129
|
+
w("</sheetData></worksheet>")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------- #
|
|
133
|
+
## Dispatch
|
|
134
|
+
# ---------------------------------------------------------------------- #
|
|
135
|
+
def export_table(path, columns, rows, fmt=None, sheet_name="Sheet1"):
|
|
136
|
+
fmt = (fmt or path.rsplit(".", 1)[-1]).lower()
|
|
137
|
+
if fmt == "csv":
|
|
138
|
+
export_csv(path, columns, rows)
|
|
139
|
+
elif fmt in ("xlsx", "xls"):
|
|
140
|
+
export_xlsx(path, columns, rows, sheet_name=sheet_name)
|
|
141
|
+
else:
|
|
142
|
+
raise ValueError("unsupported export format: %r (use .csv or .xlsx)" % fmt)
|
|
143
|
+
return fmt
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
## SPINN DESIGN CODE
|
|
3
|
+
# WEBSITE: customwidgets.org
|
|
4
|
+
########################################################################
|
|
5
|
+
"""Frozen (pinned) leftmost columns for DataTable Pro.
|
|
6
|
+
|
|
7
|
+
Classic Qt technique: a second QTableView overlays the left of the main view,
|
|
8
|
+
shares the same model and selection model, shows only the frozen columns, and
|
|
9
|
+
is kept in sync on vertical scroll, column resize and geometry changes. The
|
|
10
|
+
main view scrolls horizontally underneath while the frozen overlay stays put.
|
|
11
|
+
"""
|
|
12
|
+
from qtpy.QtCore import Qt
|
|
13
|
+
from qtpy.QtWidgets import QTableView, QFrame, QHeaderView
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FrozenHostView(QTableView):
|
|
17
|
+
"""The main table view; hosts a frozen overlay for the first N columns.
|
|
18
|
+
|
|
19
|
+
QCustomDataTablePro uses this as its view (via _createView). Call
|
|
20
|
+
setFrozenColumnCount(n) to pin the leftmost n columns.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, parent=None):
|
|
24
|
+
super().__init__(parent)
|
|
25
|
+
self._frozen = None
|
|
26
|
+
self._count = 0
|
|
27
|
+
|
|
28
|
+
# ------------------------------------------------------------------ #
|
|
29
|
+
## Public
|
|
30
|
+
# ------------------------------------------------------------------ #
|
|
31
|
+
def frozenColumnCount(self):
|
|
32
|
+
return self._count
|
|
33
|
+
|
|
34
|
+
def setFrozenColumnCount(self, n):
|
|
35
|
+
n = max(0, int(n))
|
|
36
|
+
self._count = n
|
|
37
|
+
if n <= 0:
|
|
38
|
+
if self._frozen is not None:
|
|
39
|
+
self._frozen.setParent(None)
|
|
40
|
+
self._frozen.deleteLater()
|
|
41
|
+
self._frozen = None
|
|
42
|
+
return
|
|
43
|
+
if self._frozen is None:
|
|
44
|
+
self._frozen = QTableView(self)
|
|
45
|
+
self._setupFrozen()
|
|
46
|
+
self._applyFrozen()
|
|
47
|
+
|
|
48
|
+
def frozenView(self):
|
|
49
|
+
return self._frozen
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
## Overlay setup / sync
|
|
53
|
+
# ------------------------------------------------------------------ #
|
|
54
|
+
def _setupFrozen(self):
|
|
55
|
+
f = self._frozen
|
|
56
|
+
f.setObjectName("dataTableView") # inherit the token QSS
|
|
57
|
+
if self.model() is not None:
|
|
58
|
+
f.setModel(self.model())
|
|
59
|
+
f.setSelectionModel(self.selectionModel())
|
|
60
|
+
f.setFocusPolicy(Qt.NoFocus)
|
|
61
|
+
f.setFrameShape(QFrame.NoFrame)
|
|
62
|
+
f.verticalHeader().setVisible(False)
|
|
63
|
+
f.horizontalHeader().setStretchLastSection(False)
|
|
64
|
+
f.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
65
|
+
f.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
66
|
+
f.setEditTriggers(self.editTriggers())
|
|
67
|
+
f.setSelectionBehavior(self.selectionBehavior())
|
|
68
|
+
f.setSelectionMode(self.selectionMode())
|
|
69
|
+
f.setShowGrid(self.showGrid())
|
|
70
|
+
f.setAlternatingRowColors(self.alternatingRowColors())
|
|
71
|
+
# keep the two views vertically in lock-step
|
|
72
|
+
self.verticalScrollBar().valueChanged.connect(f.verticalScrollBar().setValue)
|
|
73
|
+
f.verticalScrollBar().valueChanged.connect(self.verticalScrollBar().setValue)
|
|
74
|
+
self.horizontalHeader().sectionResized.connect(self._onSectionResized)
|
|
75
|
+
f.show()
|
|
76
|
+
|
|
77
|
+
def _applyFrozen(self):
|
|
78
|
+
f = self._frozen
|
|
79
|
+
model = self.model()
|
|
80
|
+
cols = model.columnCount() if model is not None else 0
|
|
81
|
+
for c in range(cols):
|
|
82
|
+
f.setColumnHidden(c, c >= self._count)
|
|
83
|
+
if c < self._count:
|
|
84
|
+
f.setColumnWidth(c, self.columnWidth(c))
|
|
85
|
+
self._updateFrozenGeometry()
|
|
86
|
+
|
|
87
|
+
def _onSectionResized(self, index, _old, new):
|
|
88
|
+
if self._frozen is not None and index < self._count:
|
|
89
|
+
self._frozen.setColumnWidth(index, new)
|
|
90
|
+
self._updateFrozenGeometry()
|
|
91
|
+
|
|
92
|
+
def _updateFrozenGeometry(self):
|
|
93
|
+
if self._frozen is None:
|
|
94
|
+
return
|
|
95
|
+
width = sum(self.columnWidth(c) for c in range(self._count))
|
|
96
|
+
height = self.viewport().height() + self.horizontalHeader().height()
|
|
97
|
+
fw = self.frameWidth()
|
|
98
|
+
self._frozen.setGeometry(fw, fw, width, height)
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
## Keep the overlay valid across model/geometry changes
|
|
102
|
+
# ------------------------------------------------------------------ #
|
|
103
|
+
def setModel(self, model):
|
|
104
|
+
super().setModel(model)
|
|
105
|
+
if self._frozen is not None:
|
|
106
|
+
self._frozen.setModel(model)
|
|
107
|
+
self._frozen.setSelectionModel(self.selectionModel())
|
|
108
|
+
self._applyFrozen()
|
|
109
|
+
|
|
110
|
+
def resizeEvent(self, e):
|
|
111
|
+
super().resizeEvent(e)
|
|
112
|
+
self._updateFrozenGeometry()
|