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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- 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())
|