composeui 0.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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,703 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional, Union
4
+
5
+ from qtpy.QtCore import (
6
+ QAbstractTableModel,
7
+ QEvent,
8
+ QItemSelection,
9
+ QItemSelectionModel,
10
+ QModelIndex,
11
+ QObject,
12
+ QPersistentModelIndex,
13
+ QRect,
14
+ QSize,
15
+ Qt,
16
+ QTimer,
17
+ Signal,
18
+ )
19
+ from qtpy.QtGui import QColor, QIcon, QMouseEvent, QPainter, QPalette, QPixmap
20
+ from qtpy.QtWidgets import (
21
+ QAbstractItemDelegate,
22
+ QAbstractItemView,
23
+ QAbstractScrollArea,
24
+ QApplication,
25
+ QComboBox,
26
+ QHeaderView,
27
+ QSizePolicy,
28
+ QStyle,
29
+ QStyledItemDelegate,
30
+ QStyleOptionButton,
31
+ QStyleOptionComboBox,
32
+ QStyleOptionHeader,
33
+ QStyleOptionViewItem,
34
+ QTableView,
35
+ QWidget,
36
+ )
37
+
38
+ from composeui.core.view import (
39
+ CHECKED_TEXTS,
40
+ PLAIN_CELL,
41
+ CellKind,
42
+ CellStyle,
43
+ CellWidget,
44
+ HeaderGroup,
45
+ NodeMark,
46
+ replace_cell,
47
+ )
48
+ from composeui.qtcore.widgets.qmarkicon import mark_icon
49
+ from composeui.qtcore.widgets.qplaintext import plain_tool_tip
50
+
51
+ if TYPE_CHECKING:
52
+ from qtpy.QtCore import QAbstractItemModel
53
+ from qtpy.QtGui import QKeyEvent
54
+
55
+ _Index = Union[QModelIndex, QPersistentModelIndex]
56
+ #: the roles a cell's `CellStyle` answers
57
+ _STYLE_ROLES = (
58
+ Qt.ItemDataRole.BackgroundRole,
59
+ Qt.ItemDataRole.ForegroundRole,
60
+ Qt.ItemDataRole.DecorationRole,
61
+ Qt.ItemDataRole.ToolTipRole,
62
+ )
63
+
64
+
65
+ class QTextTableModel(QAbstractTableModel):
66
+ """A table of text over the view's own lists, without a copy per cell.
67
+
68
+ Qt asks only for the cells it draws, so a long table costs no more than a short one.
69
+ The cells of `editable_columns` are editable: an edit changing a cell replaces its
70
+ row (the view's lists aren't changed in place), then `cell_edited` emits it.
71
+
72
+ A check box column of `cell_widgets` holds checkable cells without text, toggled when
73
+ editable; a link column's text looks like a link. Neither is typed in, nor is a
74
+ button column; a combobox column is, through `QCellDelegate`'s editor.
75
+
76
+ A cell of `styles` has its background, its text black or white on it, and its mark
77
+ before its text, named by its tooltip; a disabled one is never edited. The rows have
78
+ `row_headers`, if any.
79
+ """
80
+
81
+ cell_edited = Signal(int, int, str)
82
+
83
+ def __init__(self, parent: QObject | None = None) -> None:
84
+ super().__init__(parent)
85
+ self._headers: list[str] = []
86
+ self._rows: list[list[str]] = []
87
+ self.editable_columns: list[int] = []
88
+ self.cell_widgets: dict[int, CellWidget] = {}
89
+ #: a check box drawn before a header, by column
90
+ self.header_icons: dict[int, QIcon] = {}
91
+ #: how the cells look, by row and column
92
+ self.styles: dict[tuple[int, int], CellStyle] = {}
93
+ self._row_headers: list[str] = []
94
+ #: each mark's name, its tooltip
95
+ self.mark_texts: dict[NodeMark, str] = {}
96
+
97
+ def kind(self, column: int) -> CellKind | None:
98
+ widget = self.cell_widgets.get(column)
99
+ return None if widget is None else widget.kind
100
+
101
+ def set_header_icons(self, icons: dict[int, QIcon]) -> None:
102
+ self.header_icons = icons
103
+ if len(self._headers) > 0:
104
+ self.headerDataChanged.emit(Qt.Orientation.Horizontal, 0, len(self._headers) - 1)
105
+
106
+ def set_table(
107
+ self,
108
+ headers: list[str],
109
+ rows: list[list[str]],
110
+ styles: dict[tuple[int, int], CellStyle],
111
+ row_headers: list[str],
112
+ ) -> None:
113
+ self.beginResetModel()
114
+ self._headers = headers
115
+ self._rows = rows
116
+ self.styles = styles
117
+ self._row_headers = row_headers
118
+ self.endResetModel()
119
+
120
+ def style(self, row: int, column: int) -> CellStyle:
121
+ return self.styles.get((row, column), PLAIN_CELL)
122
+
123
+ def rowCount(self, parent: _Index = QModelIndex()) -> int: # noqa: B008, N802
124
+ return 0 if parent.isValid() else len(self._rows)
125
+
126
+ def columnCount(self, parent: _Index = QModelIndex()) -> int: # noqa: B008, N802
127
+ return 0 if parent.isValid() else len(self._headers)
128
+
129
+ def text(self, row: int, column: int) -> str:
130
+ cells = self._rows[row]
131
+ # a row short of a cell shows it empty
132
+ return cells[column] if column < len(cells) else ""
133
+
134
+ def data(self, index: _Index, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
135
+ if not index.isValid():
136
+ return None
137
+ kind = self.kind(index.column())
138
+ if kind is CellKind.LINK and role in (
139
+ Qt.ItemDataRole.FontRole,
140
+ Qt.ItemDataRole.ForegroundRole,
141
+ ):
142
+ return self._link_look(role)
143
+ if role in _STYLE_ROLES:
144
+ return self._style_data(self.style(index.row(), index.column()), role)
145
+ text = self.text(index.row(), index.column())
146
+ if kind is CellKind.CHECK_BOX:
147
+ is_checked = text.casefold() == "true"
148
+ state = Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked
149
+ return state if role == Qt.ItemDataRole.CheckStateRole else None
150
+ if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
151
+ return None
152
+ return text if index.column() < len(self._rows[index.row()]) else None
153
+
154
+ def _style_data(self, style: CellStyle, role: int) -> Any:
155
+ """Get a role of the cell's look: its background, text color, mark or tooltip."""
156
+ if role == Qt.ItemDataRole.BackgroundRole:
157
+ return None if style.background is None else QColor(style.background)
158
+ if role == Qt.ItemDataRole.ForegroundRole:
159
+ # grayed by `QCellDelegate` when disabled, unless on a background
160
+ return None if style.text_color is None else QColor(style.text_color)
161
+ if style.mark is None:
162
+ return None
163
+ if role == Qt.ItemDataRole.DecorationRole:
164
+ return mark_icon(style.mark)
165
+ text = self.mark_texts.get(style.mark)
166
+ return None if text is None else plain_tool_tip(text)
167
+
168
+ @staticmethod
169
+ def _link_look(role: int) -> Any:
170
+ """Get a link's font, underlined, or its color."""
171
+ if role == Qt.ItemDataRole.ForegroundRole:
172
+ return QApplication.palette().link()
173
+ font = QApplication.font()
174
+ font.setUnderline(True)
175
+ return font
176
+
177
+ def flags(self, index: _Index) -> Qt.ItemFlag:
178
+ flags = super().flags(index)
179
+ if (
180
+ not index.isValid()
181
+ or index.column() not in self.editable_columns
182
+ or not self.style(index.row(), index.column()).is_enabled
183
+ ):
184
+ return flags
185
+ kind = self.kind(index.column())
186
+ if kind is CellKind.CHECK_BOX:
187
+ flags |= Qt.ItemFlag.ItemIsUserCheckable
188
+ elif kind in (None, CellKind.COMBOBOX):
189
+ flags |= Qt.ItemFlag.ItemIsEditable
190
+ return flags
191
+
192
+ def setData( # noqa: N802
193
+ self, index: _Index, value: Any, role: int = Qt.ItemDataRole.EditRole
194
+ ) -> bool:
195
+ if role == Qt.ItemDataRole.CheckStateRole and index.isValid():
196
+ is_checked = value in (Qt.CheckState.Checked, Qt.CheckState.Checked.value)
197
+ value, role = CHECKED_TEXTS[is_checked], Qt.ItemDataRole.EditRole
198
+ if role != Qt.ItemDataRole.EditRole or not index.isValid():
199
+ return False
200
+ row, column, text = index.row(), index.column(), str(value)
201
+ if text == self.text(row, column):
202
+ return False
203
+ self._rows = replace_cell(self._rows, row, column, text)
204
+ self.dataChanged.emit(index, index)
205
+ self.cell_edited.emit(row, column, text)
206
+ return True
207
+
208
+ def headerData( # noqa: N802
209
+ self,
210
+ section: int,
211
+ orientation: Qt.Orientation,
212
+ role: int = Qt.ItemDataRole.DisplayRole,
213
+ ) -> Any:
214
+ if orientation != Qt.Orientation.Horizontal:
215
+ is_shown = role == Qt.ItemDataRole.DisplayRole and section < len(self._row_headers)
216
+ return self._row_headers[section] if is_shown else None
217
+ if role == Qt.ItemDataRole.DisplayRole:
218
+ return self._headers[section]
219
+ if role == Qt.ItemDataRole.DecorationRole:
220
+ return self.header_icons.get(section)
221
+ return None
222
+
223
+
224
+ class QCellDelegate(QStyledItemDelegate):
225
+ """Draws a button or a combobox cell, and reports a click on a button or a link.
226
+
227
+ A button cell shows its text, then the button, a square as tall as the cell unless
228
+ its text is wider; `cell_clicked` emits a click on it, or anywhere on a link cell.
229
+ A combobox cell is a combobox's frame around its text: a click on it, or editing it,
230
+ opens a combobox of its options, the cell taking the one picked. A disabled cell is
231
+ drawn grayed, its button or link not clicked.
232
+ """
233
+
234
+ cell_clicked = Signal(int, int)
235
+ editor_created = Signal()
236
+
237
+ def __init__(self, model: QTextTableModel, parent: QObject | None = None) -> None:
238
+ super().__init__(parent)
239
+ self._model = model
240
+ self._pressed: tuple[int, int] | None = None
241
+ self._closed: set[QWidget] = set()
242
+
243
+ def _widget(self, index: _Index) -> CellWidget | None:
244
+ return self._model.cell_widgets.get(index.column())
245
+
246
+ @staticmethod
247
+ def button_rect(rect: QRect, option: QStyleOptionViewItem, text: str) -> QRect:
248
+ width = max(rect.height(), option.fontMetrics.horizontalAdvance(text) + 12)
249
+ return QRect(rect.right() - width + 1, rect.top(), width, rect.height()).adjusted(
250
+ 1, 1, -1, -1
251
+ )
252
+
253
+ def _is_disabled(self, index: _Index) -> bool:
254
+ return not self._model.style(index.row(), index.column()).is_enabled
255
+
256
+ def initStyleOption(self, option: QStyleOptionViewItem, index: _Index) -> None: # noqa: N802
257
+ super().initStyleOption(option, index)
258
+ if self._is_disabled(index):
259
+ option.state &= ~QStyle.StateFlag.State_Enabled
260
+ widget = self._widget(index)
261
+ if (
262
+ widget is not None
263
+ and widget.kind is CellKind.LINK
264
+ and option.state & QStyle.StateFlag.State_Selected
265
+ ):
266
+ # the link's color would be lost on the selection's
267
+ option.palette.setBrush(QPalette.ColorRole.Text, option.palette.highlightedText())
268
+
269
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: _Index) -> None:
270
+ widget = self._widget(index)
271
+ if widget is None or widget.kind not in (CellKind.BUTTON, CellKind.COMBOBOX):
272
+ super().paint(painter, option, index)
273
+ return
274
+ view = option.widget
275
+ style = view.style() if view is not None else QApplication.style()
276
+ item = QStyleOptionViewItem(option)
277
+ self.initStyleOption(item, index)
278
+ text, item.text = item.text, ""
279
+ # the row's background and selection, then the widget over it
280
+ style.drawControl(QStyle.ControlElement.CE_ItemViewItem, item, painter, view)
281
+ is_enabled = bool(item.state & QStyle.StateFlag.State_Enabled)
282
+ if widget.kind is CellKind.COMBOBOX:
283
+ combobox = QStyleOptionComboBox()
284
+ combobox.rect = option.rect.adjusted(2, 2, -2, -2)
285
+ combobox.currentText = text
286
+ combobox.palette = option.palette
287
+ combobox.fontMetrics = option.fontMetrics
288
+ is_editable = index.column() in self._model.editable_columns
289
+ if is_enabled and is_editable: # a disabled cell isn't
290
+ combobox.state = QStyle.StateFlag.State_Enabled
291
+ style.drawComplexControl(QStyle.ComplexControl.CC_ComboBox, combobox, painter, view)
292
+ style.drawControl(QStyle.ControlElement.CE_ComboBoxLabel, combobox, painter, view)
293
+ return
294
+ button_rect = self.button_rect(option.rect, option, widget.text)
295
+ item.text = text
296
+ item.rect = option.rect.adjusted(0, 0, -button_rect.width() - 2, 0)
297
+ style.drawControl(QStyle.ControlElement.CE_ItemViewItem, item, painter, view)
298
+ button = QStyleOptionButton()
299
+ button.rect = button_rect
300
+ button.text = widget.text
301
+ button.palette = option.palette
302
+ button.fontMetrics = option.fontMetrics
303
+ button.state = QStyle.StateFlag.State_Raised
304
+ if is_enabled:
305
+ button.state |= QStyle.StateFlag.State_Enabled
306
+ style.drawControl(QStyle.ControlElement.CE_PushButton, button, painter, view)
307
+
308
+ def editorEvent( # noqa: N802
309
+ self,
310
+ event: QEvent,
311
+ model: QAbstractItemModel,
312
+ option: QStyleOptionViewItem,
313
+ index: _Index,
314
+ ) -> bool:
315
+ widget = self._widget(index)
316
+ if event.type() == QEvent.Type.MouseButtonPress:
317
+ self._pressed = (index.row(), index.column())
318
+ if (
319
+ widget is not None
320
+ and not self._is_disabled(index)
321
+ and isinstance(event, QMouseEvent)
322
+ and event.type() == QEvent.Type.MouseButtonRelease
323
+ and event.button() == Qt.MouseButton.LeftButton
324
+ ):
325
+ position = event.position().toPoint()
326
+ if widget.kind is CellKind.LINK or (
327
+ widget.kind is CellKind.BUTTON
328
+ and self.button_rect(option.rect, option, widget.text).contains(position)
329
+ ):
330
+ self.cell_clicked.emit(index.row(), index.column())
331
+ elif (
332
+ widget.kind is CellKind.COMBOBOX
333
+ and index.column() in self._model.editable_columns
334
+ # not the release of a click that closed the popup
335
+ and self._pressed == (index.row(), index.column())
336
+ and isinstance(view := self.parent(), QAbstractItemView)
337
+ ):
338
+ # like a check box, a combobox answers one click, not Qt's double click
339
+ # (opened once the release is done: the view then resets its state, which
340
+ # must say the editor is open when the edit is answered)
341
+ handled = super().editorEvent(event, model, option, index)
342
+ cell = QPersistentModelIndex(index)
343
+ QTimer.singleShot(
344
+ 0, lambda: view.edit(self._model.index(cell.row(), cell.column()))
345
+ )
346
+ return handled
347
+ return super().editorEvent(event, model, option, index)
348
+
349
+ def createEditor( # noqa: N802
350
+ self, parent: QWidget, option: QStyleOptionViewItem, index: _Index
351
+ ) -> QWidget:
352
+ self.editor_created.emit()
353
+ widget = self._widget(index)
354
+ if widget is None or widget.kind is not CellKind.COMBOBOX:
355
+ return super().createEditor(parent, option, index)
356
+ editor = QComboBox(parent)
357
+ text = self._model.text(index.row(), index.column())
358
+ # a text the options lack still shows, picked
359
+ editor.addItems([*widget.options, *([] if text in widget.options else [text])])
360
+ editor.setCurrentText(text)
361
+
362
+ def commit() -> None:
363
+ self._closed.add(editor)
364
+ self.commitData.emit(editor)
365
+ self.closeEditor.emit(editor)
366
+
367
+ editor.activated.connect(commit)
368
+ editor.destroyed.connect(lambda: self._closed.discard(editor))
369
+ QTimer.singleShot(0, editor.showPopup)
370
+ return editor
371
+
372
+ def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
373
+ # the focus lost by an editor already committed would commit it again, to a view
374
+ # that no longer has it
375
+ if isinstance(obj, QWidget) and obj in self._closed:
376
+ # Qt sends it on some runs only, as the focus moves: no test can count on it
377
+ return False # pragma: no cover
378
+ return super().eventFilter(obj, event)
379
+
380
+ def setEditorData(self, editor: QWidget, index: _Index) -> None: # noqa: N802
381
+ if not isinstance(editor, QComboBox):
382
+ super().setEditorData(editor, index)
383
+
384
+ def setModelData( # noqa: N802
385
+ self, editor: QWidget, model: QAbstractItemModel, index: _Index
386
+ ) -> None:
387
+ if isinstance(editor, QComboBox):
388
+ model.setData(index, editor.currentText())
389
+ else:
390
+ super().setModelData(editor, model, index)
391
+
392
+
393
+ class QCheckHeader(QHeaderView):
394
+ """A horizontal header whose check-all columns start with a check box.
395
+
396
+ A click on a column's box emits `check_clicked` instead of `sectionClicked`.
397
+
398
+ With `groups`, it is twice as tall: a group's text over its columns' headers, and
399
+ the header of a column without a group the whole height. A click on a group does
400
+ nothing.
401
+ """
402
+
403
+ check_clicked = Signal(int)
404
+
405
+ def __init__(self, parent: QWidget | None = None) -> None:
406
+ super().__init__(Qt.Orientation.Horizontal, parent)
407
+ self.checkable_columns: list[int] = []
408
+ self._groups: list[HeaderGroup] = []
409
+
410
+ def set_groups(self, groups: list[HeaderGroup]) -> None:
411
+ self._groups = groups
412
+ self.updateGeometry()
413
+ self.viewport().update()
414
+
415
+ def _group(self, column: int) -> HeaderGroup | None:
416
+ return next((group for group in self._groups if group.covers(column)), None)
417
+
418
+ def sizeHint(self) -> QSize: # noqa: N802
419
+ size = super().sizeHint()
420
+ if len(self._groups) > 0:
421
+ size.setHeight(2 * size.height())
422
+ return size
423
+
424
+ def paintSection(self, painter: QPainter, rect: QRect, logicalIndex: int) -> None: # noqa: N802, N803
425
+ group = self._group(logicalIndex)
426
+ if group is None:
427
+ super().paintSection(painter, rect, logicalIndex)
428
+ return
429
+ half = rect.height() // 2
430
+ # the group over its columns, drawn again with each, then the column's own header
431
+ last = min(group.column + group.column_count, self.count()) - 1
432
+ left = self.sectionViewportPosition(group.column)
433
+ right = self.sectionViewportPosition(last) + self.sectionSize(last)
434
+ option = QStyleOptionHeader()
435
+ self.initStyleOption(option)
436
+ option.rect = QRect(left, rect.top(), right - left, half)
437
+ option.text = group.text
438
+ option.textAlignment = Qt.AlignmentFlag.AlignCenter
439
+ option.position = QStyleOptionHeader.SectionPosition.OnlyOneSection
440
+ painter.save()
441
+ painter.setClipRect(option.rect)
442
+ self.style().drawControl(QStyle.ControlElement.CE_Header, option, painter, self)
443
+ painter.restore()
444
+ super().paintSection(painter, rect.adjusted(0, half, 0, 0), logicalIndex)
445
+
446
+ def _is_on_group(self, event: QMouseEvent) -> bool:
447
+ point = event.position().toPoint()
448
+ return (
449
+ self._group(self.logicalIndexAt(point.x())) is not None
450
+ and point.y() < self.height() // 2
451
+ )
452
+
453
+ def box_width(self) -> int:
454
+ """Get the width at a section's start a click checks in: its margins and box."""
455
+ margin = self.style().pixelMetric(QStyle.PixelMetric.PM_HeaderMargin, None, self)
456
+ indicator = self.style().pixelMetric(QStyle.PixelMetric.PM_IndicatorWidth, None, self)
457
+ return 2 * margin + indicator
458
+
459
+ def _checked_column(self, event: QMouseEvent) -> int | None:
460
+ x = event.position().toPoint().x()
461
+ column = self.logicalIndexAt(x)
462
+ if column not in self.checkable_columns or self._is_on_group(event):
463
+ return None
464
+ return column if x - self.sectionViewportPosition(column) <= self.box_width() else None
465
+
466
+ def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
467
+ if self._checked_column(event) is None and not self._is_on_group(event):
468
+ super().mousePressEvent(event)
469
+
470
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None: # noqa: N802
471
+ column = self._checked_column(event)
472
+ if self._is_on_group(event):
473
+ return
474
+ if column is None:
475
+ super().mouseReleaseEvent(event)
476
+ elif event.button() == Qt.MouseButton.LeftButton:
477
+ self.check_clicked.emit(column)
478
+
479
+
480
+ class QTextTableView(QTableView):
481
+ """A table of text, with no row numbers, where the user selects rows.
482
+
483
+ `set_table` gives the rows how their cells look (see `QTextTableModel`), a cell
484
+ spanning the rows under it by its `row_span`, and the rows' headers, shown on the
485
+ left unless there are none. `set_header_groups` puts headers over the columns'.
486
+
487
+ One row at most by default; with `set_multiple_selection`, each click adds or removes
488
+ a row, as Qt's `MultiSelection`. `selected_rows_changed` emits the selected rows'
489
+ indexes, in order, only when the user changes them: setting them or the table from
490
+ code stays silent. The user edits a cell of `set_editable_columns` with a double click
491
+ or F2, and `cell_edited` emits its row, column and new text once the editor has closed.
492
+ `header_clicked` emits the column whose header the user clicked; the header shows the
493
+ order `set_sort_indicator` gives, the table never sorting its rows itself.
494
+
495
+ The columns of `set_cell_widgets` show widgets (see `QCellDelegate`): a click or
496
+ Space toggles an editable check box at once, `cell_edited` emitting it; a click on a
497
+ button or a link, or Space on its cell, emits `cell_clicked`. A check-all column's
498
+ header starts with the box `set_checked_columns` shows, whose click emits
499
+ `check_all_clicked`.
500
+ """
501
+
502
+ selected_rows_changed = Signal(list)
503
+ cell_edited = Signal(int, int, str)
504
+ header_clicked = Signal(int)
505
+ cell_clicked = Signal(int, int)
506
+ check_all_clicked = Signal(int)
507
+
508
+ def __init__(self) -> None:
509
+ super().__init__()
510
+ self._model = QTextTableModel(self)
511
+ self.setModel(self._model)
512
+ header = QCheckHeader(self)
513
+ self.setHorizontalHeader(header)
514
+ header.check_clicked.connect(self.check_all_clicked)
515
+ delegate = QCellDelegate(self._model, self)
516
+ self.setItemDelegate(delegate)
517
+ delegate.cell_clicked.connect(self.cell_clicked)
518
+ # the view's state can't say it: Qt resets it after some of the ways to open an editor
519
+ self._has_editor = False
520
+ delegate.editor_created.connect(self._note_editor)
521
+ self._is_setting = False
522
+ self.verticalHeader().setVisible(False)
523
+ # as tall as its rows, not Qt's default height, which leaves an empty block
524
+ self.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents)
525
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
526
+ # the columns share the width; light rows instead of a box around each cell
527
+ self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
528
+ self.horizontalHeader().setDefaultAlignment(
529
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
530
+ )
531
+ self.verticalHeader().setDefaultSectionSize(self.fontMetrics().height() + 10)
532
+ self.setShowGrid(False)
533
+ self.setAlternatingRowColors(True)
534
+ self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
535
+ self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
536
+ self.selectionModel().selectionChanged.connect(self._emit_selected_rows_changed)
537
+ self._edits: list[tuple[int, int, str]] = []
538
+ self._model.cell_edited.connect(self._hold_edit)
539
+ self._sort: tuple[int, bool] | None = None
540
+ self.horizontalHeader().sectionClicked.connect(self._header_clicked)
541
+
542
+ def _hold_edit(self, row: int, column: int, text: str) -> None:
543
+ # Qt commits the editor's text before closing it: a callback answering the edit with
544
+ # new rows would reset the model under the open editor, which Qt then commits again
545
+ # ("commitData called with an editor that does not belong to this view")
546
+ if self._has_editor or self.state() is QAbstractItemView.State.EditingState:
547
+ self._edits.append((row, column, text))
548
+ else: # a check box toggled, no editor open
549
+ self.cell_edited.emit(row, column, text)
550
+
551
+ def _note_editor(self) -> None:
552
+ self._has_editor = True
553
+
554
+ def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
555
+ index = self.currentIndex()
556
+ kind = self._model.kind(index.column()) if index.isValid() else None
557
+ if (
558
+ event.key() == Qt.Key.Key_Space
559
+ and kind in (CellKind.BUTTON, CellKind.LINK)
560
+ and self._model.style(index.row(), index.column()).is_enabled
561
+ ):
562
+ self.cell_clicked.emit(index.row(), index.column())
563
+ return
564
+ super().keyPressEvent(event)
565
+
566
+ def set_cell_widgets(self, widgets: dict[int, CellWidget]) -> None:
567
+ self._model.cell_widgets = widgets
568
+ header = self.horizontalHeader()
569
+ assert isinstance(header, QCheckHeader)
570
+ header.checkable_columns = [
571
+ column
572
+ for column, widget in widgets.items()
573
+ if widget.kind is CellKind.CHECK_BOX and widget.check_all
574
+ ]
575
+ self.viewport().update()
576
+
577
+ def set_checked_columns(self, states: dict[int, tuple[Optional[bool], bool]]) -> None:
578
+ """Show each check-all column's box: checked, unchecked or partly (None), enabled."""
579
+ self._model.set_header_icons(
580
+ {
581
+ column: self._check_icon(is_checked, is_enabled=is_enabled)
582
+ for column, (is_checked, is_enabled) in states.items()
583
+ }
584
+ )
585
+
586
+ def _check_icon(self, is_checked: Optional[bool], *, is_enabled: bool) -> QIcon:
587
+ style = self.style()
588
+ width = style.pixelMetric(QStyle.PixelMetric.PM_IndicatorWidth, None, self)
589
+ height = style.pixelMetric(QStyle.PixelMetric.PM_IndicatorHeight, None, self)
590
+ ratio = self.devicePixelRatio()
591
+ pixmap = QPixmap(round(width * ratio), round(height * ratio))
592
+ pixmap.setDevicePixelRatio(ratio)
593
+ pixmap.fill(Qt.GlobalColor.transparent)
594
+ option = QStyleOptionButton()
595
+ option.rect = QRect(0, 0, width, height)
596
+ option.palette = self.palette()
597
+ states = {
598
+ True: QStyle.StateFlag.State_On,
599
+ False: QStyle.StateFlag.State_Off,
600
+ None: QStyle.StateFlag.State_NoChange,
601
+ }
602
+ option.state = states[is_checked]
603
+ if is_enabled:
604
+ option.state |= QStyle.StateFlag.State_Enabled
605
+ else:
606
+ # Fusion draws the box in the palette's colors, whatever its state
607
+ option.palette.setCurrentColorGroup(QPalette.ColorGroup.Disabled)
608
+ painter = QPainter(pixmap)
609
+ try:
610
+ style.drawPrimitive(QStyle.PrimitiveElement.PE_IndicatorCheckBox, option, painter, self)
611
+ finally:
612
+ painter.end()
613
+ return QIcon(pixmap)
614
+
615
+ def closeEditor( # noqa: N802
616
+ self, editor: QWidget, hint: QAbstractItemDelegate.EndEditHint
617
+ ) -> None:
618
+ if len(self._edits) > 0:
619
+ # the cell to edit next may not stay where it is once the edit is answered
620
+ hint = QAbstractItemDelegate.EndEditHint.NoHint
621
+ super().closeEditor(editor, hint)
622
+ self._has_editor = False
623
+ edits, self._edits = self._edits, []
624
+ for edit in edits:
625
+ self.cell_edited.emit(*edit)
626
+
627
+ def set_sort_indicator(self, sort: tuple[int, bool] | None, *, is_shown: bool) -> None:
628
+ """Show the order: by a column, descending or not; None for the rows' own."""
629
+ self._sort = sort
630
+ header = self.horizontalHeader()
631
+ header.setSortIndicatorShown(is_shown and sort is not None)
632
+ header.setSectionsClickable(is_shown)
633
+ if sort is not None:
634
+ column, is_descending = sort
635
+ order = Qt.SortOrder.DescendingOrder if is_descending else Qt.SortOrder.AscendingOrder
636
+ header.setSortIndicator(column, order)
637
+
638
+ def _header_clicked(self, column: int) -> None:
639
+ # the header flips its indicator on a click: the order is the view's to say
640
+ self.set_sort_indicator(self._sort, is_shown=self.horizontalHeader().sectionsClickable())
641
+ self.header_clicked.emit(column)
642
+
643
+ def set_table(
644
+ self,
645
+ headers: list[str],
646
+ rows: list[list[str]],
647
+ styles: dict[tuple[int, int], CellStyle] | None = None,
648
+ row_headers: list[str] | None = None,
649
+ ) -> None:
650
+ styles, row_headers = styles or {}, row_headers or []
651
+ self._is_setting = True
652
+ try:
653
+ self._model.set_table(headers, rows, styles, row_headers)
654
+ self.clearSpans()
655
+ for (row, column), style in styles.items():
656
+ if style.row_span > 1:
657
+ self.setSpan(row, column, style.row_span, 1)
658
+ finally:
659
+ self._is_setting = False
660
+ self.verticalHeader().setVisible(len(row_headers) > 0)
661
+
662
+ def set_header_groups(self, groups: list[HeaderGroup]) -> None:
663
+ header = self.horizontalHeader()
664
+ assert isinstance(header, QCheckHeader)
665
+ header.set_groups(groups)
666
+
667
+ def set_mark_text(self, mark: NodeMark, text: str) -> None:
668
+ """Name `mark` in its cells' tooltips."""
669
+ self._model.mark_texts[mark] = text
670
+
671
+ def set_editable_columns(self, columns: list[int]) -> None:
672
+ self._model.editable_columns = columns
673
+
674
+ def set_multiple_selection(self, *, is_multiple: bool) -> None:
675
+ self.setSelectionMode(
676
+ QAbstractItemView.SelectionMode.MultiSelection
677
+ if is_multiple
678
+ else QAbstractItemView.SelectionMode.SingleSelection
679
+ )
680
+
681
+ def selected_rows(self) -> list[int]:
682
+ return sorted(index.row() for index in self.selectionModel().selectedRows())
683
+
684
+ def set_selected_rows(self, rows: list[int]) -> None:
685
+ self._is_setting = True
686
+ try:
687
+ selection = QItemSelection()
688
+ for row in rows:
689
+ index = self._model.index(row, 0)
690
+ selection.select(index, index)
691
+ self.selectionModel().select(
692
+ selection,
693
+ QItemSelectionModel.SelectionFlag.ClearAndSelect
694
+ | QItemSelectionModel.SelectionFlag.Rows,
695
+ )
696
+ finally:
697
+ self._is_setting = False
698
+
699
+ def _emit_selected_rows_changed(
700
+ self, _selected: QItemSelection, _deselected: QItemSelection
701
+ ) -> None:
702
+ if not self._is_setting:
703
+ self.selected_rows_changed.emit(self.selected_rows())